commit 3362790bfdfdc0e07f37675fbfc9d745d1030f2c Author: 汉秋 <497055328@qq.com> Date: Tue Aug 4 13:55:20 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f58e20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/.kotlin/ +/.idea/ + +# Signing credentials and generated packages +*.jks +*.keystore +*.apk +*.aab diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ae9309 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# ZhiFlyFollow + + + +## Getting started + +To make it easy for you to get started with GitLab, here's a list of recommended next steps. + +Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! + +## Add your files + +- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files +- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: + +``` +cd existing_repo +git remote add origin http://47.97.8.103/develop/android/zhiflyfollow.git +git branch -M main +git push -uf origin main +``` + +## Integrate with your tools + +- [ ] [Set up project integrations](http://47.97.8.103/develop/android/zhiflyfollow/-/settings/integrations) + +## Collaborate with your team + +- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) +- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) +- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) +- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) +- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) + +## Test and Deploy + +Use the built-in continuous integration in GitLab. + +- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) +- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) +- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) +- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) +- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) + +*** + +# Editing this README + +When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. + +## Suggestions for a good README +Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. + +## Name +Choose a self-explaining name for your project. + +## Description +Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. + +## Badges +On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. + +## Visuals +Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. + +## Installation +Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. + +## Usage +Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. + +## Support +Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. + +## Roadmap +If you have ideas for releases in the future, it is a good idea to list them in the README. + +## Contributing +State if you are open to contributions and what your requirements are for accepting them. + +For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. + +You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. + +## Authors and acknowledgment +Show your appreciation to those who have contributed to the project. + +## License +For open source projects, say how it is licensed. + +## Project status +If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d9b269b --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,346 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date + +/** + * 本地库依赖管理 + * 统一管理打印机和高德地图等本地库文件 + */ +object LocalLibraries { + private const val LIBS_DIR = "libs" + + /** + * 高德地图库 + */ + object Amap { + const val VERSION = "10.1.600_AMapSearch_9.7.4_AMapLocation_6.5.1_20251020" + fun getCombinedJar(projectDir: File): File { + return File(projectDir, "$LIBS_DIR/AMap3DMap_${VERSION}.jar") + } + } + + object Nuisdk{ + + fun getNui(projectDir: File): File = File(projectDir, "$LIBS_DIR/nuisdk-release.aar") + + fun getFastjson(projectDir: File): File = File(projectDir, "$LIBS_DIR/fastjson-1.1.46.android.jar") + + } + + /** + * 打印机库 + */ + object Printer { + const val DNPEXT_VERSION = "1.0" + const val COLORCNV_VERSION = "1.2" + const val PHOTOPRINTLIB_VERSION = "1.2" + + fun getDnpext(projectDir: File): File = File(projectDir, "$LIBS_DIR/dnpext.aar") + fun getColorcnv(projectDir: File): File = File(projectDir, "$LIBS_DIR/dnpphotocolorcnv-${COLORCNV_VERSION}.aar") + fun getPhotoprintlib(projectDir: File): File = File(projectDir, "$LIBS_DIR/dnpphotoprintlib-${PHOTOPRINTLIB_VERSION}.aar") + } + + /** + * 获取所有高德地图依赖 + */ + fun getAmapDependencies(projectDir: File): List { + return listOf(Amap.getCombinedJar(projectDir)) + } + + /** + * 获取所有打印机依赖 + */ + fun getPrinterDependencies(projectDir: File): List { + return listOf( + Printer.getDnpext(projectDir), + Printer.getColorcnv(projectDir), + Printer.getPhotoprintlib(projectDir) + ) + } + + /** + * 获取语音合成依赖 + */ + fun getNui(projectDir: File): List { + return listOf( + Nuisdk.getNui(projectDir), + Nuisdk.getFastjson(projectDir), + ) + } + + + +} + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.yzx.kiosk" + compileSdk = 36 + + defaultConfig { + applicationId = "com.yzx.kiosk" + minSdk = 26 + targetSdk = 36 + versionCode = 21 + versionName = "1.1.1" + //multiDexEnabled = true + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + + abiFilters.addAll( + listOf( + "armeabi", + "armeabi-v7a", + "arm64-v8a", + "x86", + "x86_64", + ) + ) + } + + } + + signingConfigs { + getByName("debug") { + storeFile = file("keystore.jks") + keyAlias = "zczx" + keyPassword = "9sgcXfpi4N0vTY6F9tEJ" + storePassword = "9sgcXfpi4N0vTY6F9tEJ" + } + + create("release") { + storeFile = file("yzxkey.jks") + keyAlias = "yzx" + keyPassword = "yzx@6688@YZ" + storePassword = "yzx@6688@YZ" + } + } + + buildTypes { + debug { + isDebuggable = true + isMinifyEnabled = false + isShrinkResources = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField("String", "BASE_URL", "\"https://api-test.zhifly.cn\"") + buildConfigField("String", "WSS", "\"wss://api-test.zhifly.cn/wss\"") + buildConfigField( + "String", + "CLOUD_OSS_URL", + "\"https://facebox.oss-cn-shanghai.aliyuncs.com\"" + ) + } + + release { + isDebuggable = false + isMinifyEnabled = false + isShrinkResources = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + buildConfigField("String", "BASE_URL", "\"https://api.zhifly.cn\"") + buildConfigField("String", "WSS", "\"wss://api.zhifly.cn/wss\"") + buildConfigField( + "String", + "CLOUD_OSS_URL", + "\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\"" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + languageVersion = "2.0" + } + + buildFeatures { + compose = true + buildConfig = true + viewBinding = true + } + + packaging { + resources { + // 排除 META-INF/INDEX.LIST 文件,解决多个 netty 库的冲突 + excludes += "/META-INF/INDEX.LIST" + // 排除 netty 版本属性文件,解决多个 netty 库的冲突 + excludes += "/META-INF/io.netty.versions.properties" + // 排除其他可能冲突的 META-INF 文件 + excludes += "/META-INF/*.SF" + excludes += "/META-INF/*.DSA" + excludes += "/META-INF/*.RSA" + } + } + + android.applicationVariants.all { + val variant = this + variant.outputs.all { + val outputImpl = this as BaseVariantOutputImpl + val appName = "照片打印机" + val versionName = variant.versionName + val versionCode = variant.versionCode + val buildType = variant.buildType.name + val time = SimpleDateFormat("yyyyMMdd_HHmm").format(Date()) + + val apkName = "${appName}_${versionName}_${versionCode}_${buildType}_${time}.apk" + outputImpl.outputFileName = apkName + } + } + + sourceSets { + named("main") { + jniLibs.srcDirs("libs") + } + } +} + +// 全局排除 fastjson 1.2.41,统一使用本地 fastjson-1.1.46.android.jar +configurations.all { + exclude(group = "com.alibaba", module = "fastjson") +} + +dependencies { + + + implementation(libs.androidx.compose.foundation) + implementation(libs.firebase.crashlytics.buildtools) + compileOnly(libs.ksp.gradlePlugin) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + implementation(libs.constraintlayout.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.material.icon) + implementation(libs.material.icon.extended) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + ksp(libs.hilt.android.compiler) + kspAndroidTest(libs.hilt.android.compiler) + androidTestImplementation(libs.hilt.android.testing) + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + + //debugImplementation(libs.leakcanary.android) 20250707 + + implementation(libs.navigation.compose) + + implementation(libs.okhttp) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.logging.interceptor) + + implementation(libs.gson) + + implementation(libs.toaster) + + implementation(libs.mmkv) + + implementation(libs.coil.compose) + + implementation(libs.accompanist.permissions) + implementation(libs.accompanist.systemuicontroller) + + implementation(libs.lottie.compose) + + implementation(libs.google.zxing) + + implementation(libs.ucrop) + + implementation(libs.wheelPicker.compose) + + implementation(libs.androidx.material3) + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.datasource.rtmp) + implementation(libs.androidx.media3.ui) + + implementation(libs.barcode.scanning) + + implementation(libs.camerax.camera2) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) + + // 排除传递依赖中的 fastjson,使用本地 fastjson-1.1.46.android.jar + implementation(libs.aliyun) { + exclude(group = "com.alibaba", module = "fastjson") + } + implementation(libs.aliyuntts) { + exclude(group = "com.alibaba", module = "fastjson") + } + + // JAXB for Android (解决 javax.xml.bind.DatatypeConverter 不可用问题) + // 阿里云 SDK 使用了 javax.xml.bind.DatatypeConverter,在 Android 上需要添加这些依赖 + implementation(libs.jaxb.api) + implementation(libs.jaxb.runtime) + // Apache Xerces 提供 XML 处理实现(解决 DatatypeFactoryImpl 不可用问题) + implementation(libs.xerces) + + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler.ksp) + + implementation(libs.lucksiege.pictureselector) +// implementation(libs.lucksiege.compress) + //implementation(libs.lucksiege.ucrop) + implementation(libs.lucksiege.camerax) + implementation(libs.glide) + ksp(libs.glide.compiler) + + implementation(libs.lifecycle.viewmodel.ktx) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.androidx.activityCompose) + + implementation(libs.androidx.localbroadcastmanager) + + implementation(libs.paging.runtime) + + implementation(libs.paging.compose) + + // ========== 本地库依赖管理 ========== + // 高德地图库 + LocalLibraries.getAmapDependencies(projectDir).forEach { file -> + implementation(files(file)) + } + + // 打印机库 + LocalLibraries.getPrinterDependencies(projectDir).forEach { file -> + implementation(files(file)) + } + // 阿里云语音合成 + LocalLibraries.getNui(projectDir).forEach { file -> + implementation(files(file)) + } + + +} \ No newline at end of file diff --git a/app/libs/AMap3DMap_10.1.600_AMapSearch_9.7.4_AMapLocation_6.5.1_20251020.jar b/app/libs/AMap3DMap_10.1.600_AMapSearch_9.7.4_AMapLocation_6.5.1_20251020.jar new file mode 100644 index 0000000..9924e5e Binary files /dev/null and b/app/libs/AMap3DMap_10.1.600_AMapSearch_9.7.4_AMapLocation_6.5.1_20251020.jar differ diff --git a/app/libs/android-support-v4.jar b/app/libs/android-support-v4.jar new file mode 100644 index 0000000..2ff47f4 Binary files /dev/null and b/app/libs/android-support-v4.jar differ diff --git a/app/libs/arm64-v8a/libAMapSDK_MAP_v10_1_600.so b/app/libs/arm64-v8a/libAMapSDK_MAP_v10_1_600.so new file mode 100644 index 0000000..97deb91 Binary files /dev/null and b/app/libs/arm64-v8a/libAMapSDK_MAP_v10_1_600.so differ diff --git a/app/libs/armeabi-v7a/libAMapSDK_MAP_v10_1_600.so b/app/libs/armeabi-v7a/libAMapSDK_MAP_v10_1_600.so new file mode 100644 index 0000000..a059805 Binary files /dev/null and b/app/libs/armeabi-v7a/libAMapSDK_MAP_v10_1_600.so differ diff --git a/app/libs/com.heytap.msp-3.5.3.aar b/app/libs/com.heytap.msp-3.5.3.aar new file mode 100644 index 0000000..b2b7631 Binary files /dev/null and b/app/libs/com.heytap.msp-3.5.3.aar differ diff --git a/app/libs/dnpext.aar b/app/libs/dnpext.aar new file mode 100644 index 0000000..2688d65 Binary files /dev/null and b/app/libs/dnpext.aar differ diff --git a/app/libs/dnpphotocolorcnv-1.2.aar b/app/libs/dnpphotocolorcnv-1.2.aar new file mode 100644 index 0000000..d304109 Binary files /dev/null and b/app/libs/dnpphotocolorcnv-1.2.aar differ diff --git a/app/libs/dnpphotoprintlib-1.2.aar b/app/libs/dnpphotoprintlib-1.2.aar new file mode 100644 index 0000000..da4c057 Binary files /dev/null and b/app/libs/dnpphotoprintlib-1.2.aar differ diff --git a/app/libs/fastjson-1.1.46.android.jar b/app/libs/fastjson-1.1.46.android.jar new file mode 100644 index 0000000..1d607f2 Binary files /dev/null and b/app/libs/fastjson-1.1.46.android.jar differ diff --git a/app/libs/nuisdk-release.aar b/app/libs/nuisdk-release.aar new file mode 100644 index 0000000..502131c Binary files /dev/null and b/app/libs/nuisdk-release.aar differ diff --git a/app/libs/push-8.0.12.307.aar b/app/libs/push-8.0.12.307.aar new file mode 100644 index 0000000..c6514d7 Binary files /dev/null and b/app/libs/push-8.0.12.307.aar differ diff --git a/app/libs/push-internal-5.0.5.aar b/app/libs/push-internal-5.0.5.aar new file mode 100644 index 0000000..9cbf7bb Binary files /dev/null and b/app/libs/push-internal-5.0.5.aar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a061834 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,35 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-dontwarn com.yalantis.ucrop** +-keep class com.yalantis.ucrop** { *; } +-keep interface com.yalantis.ucrop** { *; } + +-keep class com.alibaba.sdk.android.oss.** { *; } +-dontwarn okio.** +-dontwarn org.apache.commons.codec.binary.** + +-keep class com.google.gson.reflect.TypeToken +-keep class * extends com.google.gson.reflect.TypeToken +-keep public class * implements java.lang.reflect.Type + +-keep class androidx.core.splashscreen.SplashScreenViewProvider { *; } \ No newline at end of file diff --git a/app/src/androidTest/java/com/yzx/kiosk/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/yzx/kiosk/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..cc9185f --- /dev/null +++ b/app/src/androidTest/java/com/yzx/kiosk/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.yzx.kiosk + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.zhifly.follow", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7c2e5ab --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/App.kt b/app/src/main/java/com/yzx/kiosk/App.kt new file mode 100644 index 0000000..3954d5b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/App.kt @@ -0,0 +1,123 @@ +package com.yzx.kiosk + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import coil.Coil +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache +import com.amap.api.location.AMapLocationClient +import com.luck.picture.lib.basic.PictureSelectorSupporterActivity +import com.luck.picture.lib.basic.PictureSelectorTransparentActivity +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.MMKVUtils +import com.yzx.kiosk.utils.NavigationBarUtil +import com.yzx.kiosk.utils.ToastUtils +import dagger.hilt.android.HiltAndroidApp +import jp.co.dnp.photoprintlib.DNPPhotoPrint +import javax.inject.Inject + +@HiltAndroidApp +class App : Application() { + + companion object { + public lateinit var instance: App + + fun getDnpPhotoPrint(): DNPPhotoPrint { + if (!instance::dnpPhotoPrint.isInitialized) { + instance.dnpPhotoPrint = DNPPhotoPrint(instance) + } + return instance.dnpPhotoPrint + } + } + + @Inject + lateinit var appState: AppState + + private lateinit var dnpPhotoPrint: DNPPhotoPrint + + @Inject + lateinit var appStoreDataSource: AppStoreDataSource + + override fun onCreate() { + super.onCreate() + instance = this + // 打印机延迟初始化,在首次使用时初始化 + // dnpPhotoPrint = DNPPhotoPrint(this) + // 高德地图隐私合规初始化 - 必须在调用任何SDK接口前调用 + try { + AMapLocationClient.updatePrivacyShow(this, true, true) + AMapLocationClient.updatePrivacyAgree(this, true) + } catch (e: Exception) { + LogUtils.e("App", "高德地图隐私合规初始化失败: ${e.message}") + } + + ToastUtils.init(this) + LogUtils.init(BuildConfig.DEBUG) + MMKVUtils.init(this) + + appState.initialize() + + initCoil() + + registerActivityLifecycleCallbacks() + } + + private fun initCoil() { + // 构造全局 ImageLoader + val imageLoader = ImageLoader.Builder(this) + // 内存缓存:最大占用 15% 可用内存(降低内存占用,避免 OOM) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.15) + .build() + } + // 磁盘缓存:放在 cacheDir/image_cache,最大 50MB(降低磁盘缓存) + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("image_cache")) + .maxSizeBytes(50L * 1024 * 1024) + .build() + } + .build() + + // 设置为默认的全局 ImageLoader + Coil.setImageLoader(imageLoader) + } + + + private fun registerActivityLifecycleCallbacks() { + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is PictureSelectorSupporterActivity || activity is PictureSelectorTransparentActivity) { + NavigationBarUtil.applyNavigationBarPadding(activity); + } + } + + override fun onActivityDestroyed(p0: Activity) { + } + + override fun onActivityPaused(p0: Activity) { + } + + override fun onActivityResumed(p0: Activity) { + } + + override fun onActivitySaveInstanceState( + p0: Activity, + p1: Bundle + ) { + } + + override fun onActivityStarted(p0: Activity) { + } + + override fun onActivityStopped(p0: Activity) { + } + }) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/MainActivity.kt b/app/src/main/java/com/yzx/kiosk/MainActivity.kt new file mode 100644 index 0000000..848eb18 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/MainActivity.kt @@ -0,0 +1,313 @@ +package com.yzx.kiosk + +import android.os.Bundle +import android.view.MotionEvent +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import androidx.compose.runtime.LaunchedEffect +import com.yzx.kiosk.audio.AudioPlayService +import com.yzx.kiosk.audio.LocalAudioPlayService +import com.yzx.kiosk.component.auth.AuthExpiredDialog +import com.yzx.kiosk.component.auth.GlobalAuthDialogManager +import com.yzx.kiosk.component.loading.GlobalLoadingHost +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.navigation.AppNavHost +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.navigation.NavigationEvent +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.theme.AppTheme +import com.yzx.kiosk.ui.poster.ScenicLivePosterController +import com.yzx.kiosk.ui.poster.view.PosterData +import com.yzx.kiosk.ui.poster.view.PosterScreenWithLiveOrSlideshow +import com.yzx.kiosk.utils.InactivityManager +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.websocket.WebSocketMessageListener +import com.yzx.kiosk.websocket.WebSocketService +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + @Inject + lateinit var appState: AppState + + @Inject + lateinit var appStoreDataSource: AppStoreDataSource + + @Inject + lateinit var navigator: AppNavigator + + @Inject + lateinit var inactivityManager: InactivityManager + + @Inject + lateinit var audioPlayService: AudioPlayService + + @Inject + lateinit var localAudioPlayService: LocalAudioPlayService + + @Inject + lateinit var networkRepository: NetWorkRepository + + @Inject + lateinit var webSocketService: WebSocketService + + @Inject + lateinit var scenicLivePosterController: ScenicLivePosterController + + // WebSocket 消息监听器 + private val webSocketMessageListener = object : WebSocketMessageListener { + override fun onTokenBound(sn: String?) { + LogUtils.d("MainActivity", "WebSocket Token 绑定成功,设备 SN: $sn") + } + + override fun onMessage(message: String) { + LogUtils.d("MainActivity", "收到 WebSocket 消息: $message") + // 可以在这里处理其他类型的消息 + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // 初始化无操作检测管理器 + inactivityManager.init(lifecycleScope) + + // 注册 WebSocket 消息监听器 + webSocketService.addMessageListener(webSocketMessageListener) + + // 获取 Socket Token 并启动 WebSocket 连接 + loadSocketToken() + + // 监听导航事件,更新首页状态并播放音频 + lifecycleScope.launch { + navigator.navigationEvents.collect { event -> + when (event) { + is NavigationEvent.NavigateTo -> { + val isHome = event.route == AppRoutes.HOME || event.route.startsWith(AppRoutes.HOME + "?") + inactivityManager.setOnHomePage(isHome) + // 根据路由播放对应的本地音频,首页强制重新播放 + localAudioPlayService.playByRoute(event.route, forceRestart = isHome) + } + is NavigationEvent.NavigateBack -> { + // 返回时无法直接判断目标页面,暂不处理 + } + is NavigationEvent.NavigateBackTo -> { + val isHome = event.route == AppRoutes.HOME || event.route.startsWith(AppRoutes.HOME + "?") + inactivityManager.setOnHomePage(isHome) + // 根据路由播放对应的本地音频,首页强制重新播放 + localAudioPlayService.playByRoute(event.route, forceRestart = isHome) + } + is NavigationEvent.CloseAllExcept -> { + val isHome = event.route == AppRoutes.HOME || event.route.startsWith(AppRoutes.HOME + "?") + inactivityManager.setOnHomePage(isHome) + // 根据路由播放对应的本地音频,首页强制重新播放 + localAudioPlayService.playByRoute(event.route, forceRestart = isHome) + } + } + } + } + + // 简化启动逻辑,直接进入主页 + val startDestination = AppRoutes.HOME + + setContent { + val isShowAuthDialog by GlobalAuthDialogManager.isShown + val shouldShowPoster by inactivityManager.shouldShowPoster.collectAsState() + var posterLivePlaybackActive by remember { mutableStateOf(false) } + /** 正在显示直播海报层(含缓冲),与直播是否已 READY 无关 */ + var posterLiveLayerVisible by remember { mutableStateOf(false) } + + // 使用 remember 和 key 来响应式地读取海报列表和 BGM + // 当导航事件发生时,重新读取数据 + var refreshKey by remember { mutableStateOf(0) } + + // 监听导航事件,当返回首页时刷新数据 + LaunchedEffect(Unit) { + navigator.navigationEvents.collect { event -> + when (event) { + is NavigationEvent.NavigateBack -> { + // 返回时刷新数据 + refreshKey++ + } + is NavigationEvent.NavigateBackTo -> { + if (event.route == AppRoutes.HOME || event.route.startsWith(AppRoutes.HOME + "?")) { + refreshKey++ + } + } + is NavigationEvent.CloseAllExcept -> { + if (event.route == AppRoutes.HOME || event.route.startsWith(AppRoutes.HOME + "?")) { + refreshKey++ + } + } + else -> {} + } + } + } + + // 获取本地保存的海报列表(响应式读取) + val posterUrls = remember(refreshKey) { appStoreDataSource.getPosterList() } + val posters = remember(posterUrls) { + posterUrls.map { url -> + val extension = url.substringAfterLast(".").lowercase() + val isVideo = extension in listOf("mp4", "avi", "mov", "mkv", "webm") + PosterData(url, isVideo) + } + } + val homePageLiveEnabled = remember(refreshKey) { appStoreDataSource.getHomePageLiveEnabled() } + val scenicLiveDynamicUrl by scenicLivePosterController.dynamicRtmpUrl.collectAsState() + val effectivePosterLiveUrl = + scenicLiveDynamicUrl?.takeIf { it.isNotBlank() } ?: "" + val showScenicLiveBadge = + homePageLiveEnabled && !scenicLiveDynamicUrl.isNullOrBlank() + val showPosterOverlay = shouldShowPoster && ( + posters.isNotEmpty() || homePageLiveEnabled + ) + + // 获取BGM URL(响应式读取) + val bgmUrl = remember(refreshKey) { appStoreDataSource.getBgm() } + + LaunchedEffect(showPosterOverlay) { + if (!showPosterOverlay) { + posterLivePlaybackActive = false + posterLiveLayerVisible = false + } + } + + // 仅非直播海报(图片/视频轮播)时播放待机 BGM;直播海报整段(含缓冲)不播,失败回退轮播后再播 + LaunchedEffect(showPosterOverlay, bgmUrl, posterLiveLayerVisible) { + if (showPosterOverlay && bgmUrl.isNotEmpty() && !posterLiveLayerVisible) { + audioPlayService.setRepeatMode(androidx.media3.common.Player.REPEAT_MODE_ONE) + audioPlayService.play(bgmUrl) + } else { + audioPlayService.setRepeatMode(androidx.media3.common.Player.REPEAT_MODE_OFF) + audioPlayService.stop() + } + } + + AppTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.fillMaxSize()) { + // 主内容 - 海报显示时暂停首页视频 + GlobalLoadingHost { + AppNavHost( + navigator = navigator, + startDestination = startDestination, + isHomeVideoPlaying = !showPosterOverlay, + inactivityManager = inactivityManager + ) + } + + // 海报层:可播直播或轮播海报;退出时直播 ExoPlayer 随 Composable 释放 + if (showPosterOverlay) { + PosterScreenWithLiveOrSlideshow( + liveEnabled = homePageLiveEnabled, + liveStreamUrl = effectivePosterLiveUrl, + showScenicLiveBadge = showScenicLiveBadge, + posters = posters, + onUserInteraction = { + inactivityManager.hidePoster() + posterLivePlaybackActive = false + posterLiveLayerVisible = false + CoroutineScope(Dispatchers.Main).launch { + navigator.closeAllExcept(AppRoutes.HOME) + } + }, + onLivePlaybackActive = { active: Boolean -> + posterLivePlaybackActive = active + }, + onPosterLiveLayerVisible = { visible: Boolean -> + posterLiveLayerVisible = visible + } + ) + } + } + } + } + + if (isShowAuthDialog) { + AuthExpiredDialog( + onConfirm = { + CoroutineScope(Dispatchers.Main).launch { + GlobalAuthDialogManager.dismiss() + navigator.closeAllExcept(AppRoutes.HOME) + } + } + ) + } + } + } + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + // 海报显示时不重置计时器,只有非海报页面的触摸才重置 + if (ev?.action == MotionEvent.ACTION_DOWN && !inactivityManager.shouldShowPoster.value) { + inactivityManager.onUserActivity() + } + return super.dispatchTouchEvent(ev) + } + + /** + * 加载 Socket Token 并启动 WebSocket 连接 + * 仅在应用启动时调用,连接成功后不再刷新 token + */ + private fun loadSocketToken() { + lifecycleScope.launch { + try { + val response = networkRepository.getSocketToken().first() + if (response.code == 100000 && response.data != null) { + val socketToken = response.data.socketToken + + // 保存到本地 + appStoreDataSource.saveSocketToken(socketToken) + appStoreDataSource.saveSocketTokenExpiredAt(response.data.expiredAt) + + LogUtils.d("MainActivity", "Socket Token 加载成功") + + // 启动 WebSocket 连接 + if (!webSocketService.isConnected()) { + webSocketService.start() + } + } else { + LogUtils.e("MainActivity", "Socket Token 加载失败: ${response.message}") + } + } catch (e: Exception) { + LogUtils.e("MainActivity", "Socket Token 加载异常: ${e.message}") + } + } + } + + override fun onDestroy() { + super.onDestroy() + inactivityManager.destroy() + // 移除 WebSocket 消息监听器 + webSocketService.removeMessageListener(webSocketMessageListener) + // 停止 WebSocket 连接 + webSocketService.stop() + // 释放音频播放服务资源 + audioPlayService.release() + localAudioPlayService.release() + } +} diff --git a/app/src/main/java/com/yzx/kiosk/SplashActivity.kt b/app/src/main/java/com/yzx/kiosk/SplashActivity.kt new file mode 100644 index 0000000..159ae35 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/SplashActivity.kt @@ -0,0 +1,67 @@ +package com.yzx.kiosk + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.WindowInsets +import android.view.WindowInsetsController +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import com.yzx.kiosk.datastore.AppStoreDataSource +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@SuppressLint("CustomSplashScreen") +@AndroidEntryPoint +class SplashActivity : AppCompatActivity() { + + @Inject + lateinit var appStoreDataSource: AppStoreDataSource + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // 设置全屏模式 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars()) + window.insetsController?.systemBarsBehavior = + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + ) + } + + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT + + setContentView(R.layout.activity_splash) + + val root = findViewById(R.id.root) + ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets -> + // 全屏模式下不需要添加padding,内容可以延伸到整个屏幕 + insets + } + + + Handler(Looper.getMainLooper()).postDelayed({ + val intent = Intent(this, MainActivity::class.java) + startActivity(intent) + finish() + }, 1500) + + } + + +} diff --git a/app/src/main/java/com/yzx/kiosk/app/Constants.kt b/app/src/main/java/com/yzx/kiosk/app/Constants.kt new file mode 100644 index 0000000..48db109 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/app/Constants.kt @@ -0,0 +1,37 @@ +package com.yzx.kiosk.app + +import com.yzx.kiosk.R + + +data class HomeMenu(val icon: Int, val title: String, val uri: String) + +object Constants { + val menuList = listOf( +// HomeMenu(R.drawable.ic_home_menu_info, "基本信息", "basic_info"), + HomeMenu(R.drawable.ic_home_menu_space, "空间设置", "space_settings"), +// HomeMenu(R.drawable.ic_home_menu_data, "数据统计", "photographer_stats"), + HomeMenu(R.drawable.ic_home_menu_wallet, "我的钱包", "wallet"), +// HomeMenu(R.drawable.ic_home_menu_order, "订单管理", "photographer_orders"), + HomeMenu(R.drawable.ic_home_menu_cloud, "相册云盘", "cloud_management"), + HomeMenu(R.drawable.ic_home_menu_photo, "素材管理", "asset_management"), + HomeMenu(R.drawable.ic_home_menu_task, "任务管理", "task_management"), + HomeMenu(R.drawable.ic_home_menu_calendar, "日程管理", "schedule_management"), + HomeMenu(R.drawable.ic_home_menu_setting, "设置中心", "system_settings"), + HomeMenu(R.drawable.ic_home_menu_notification, "消息中心", "message_center"), +// HomeMenu(R.drawable.ic_home_menu_location, "位置信息", "location_info"), + HomeMenu(R.drawable.ic_home_menu_point, "打卡点管理", "checkin_points"), + HomeMenu(R.drawable.ic_home_menu_route, "样片管理", "sample_management"), + HomeMenu(R.drawable.ic_home_menu_broadcast, "直播管理", "live_stream_management"), + HomeMenu(R.drawable.ic_home_menu_approve, "核销订单", "verification_order"), + HomeMenu(R.drawable.ic_home_menu_live_album, "直播相册", "live_album"), + HomeMenu(R.drawable.ic_home_menu_pm, "项目管理", "pm"), + HomeMenu(R.drawable.ic_home_menu_location_report, "位置上报", "location_report"), + HomeMenu( + R.drawable.ic_home_menu_registration_invitation, + "注册邀请", + "registration_invitation" + ), + HomeMenu(R.drawable.ic_home_menu_store, "店铺管理", "store"), + HomeMenu(R.drawable.ic_home_menu_fly, "飞行管理", "fly"), + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/audio/AudioPlayService.kt b/app/src/main/java/com/yzx/kiosk/audio/AudioPlayService.kt new file mode 100644 index 0000000..4b1c030 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/audio/AudioPlayService.kt @@ -0,0 +1,197 @@ +package com.yzx.kiosk.audio + +import android.content.Context +import android.net.Uri +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.yzx.kiosk.utils.LogUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 音频播放服务 + * 用于播放在线音频,支持切换音频时及时播放新的音频 + */ +@Singleton +class AudioPlayService @Inject constructor( + private val context: Context +) { + private var exoPlayer: ExoPlayer? = null + private var currentAudioUrl: String? = null + + // 播放状态 + private val _isPlaying = MutableStateFlow(false) + val isPlaying: StateFlow = _isPlaying.asStateFlow() + + // 当前播放的音频URL + private val _currentUrl = MutableStateFlow(null) + val currentUrl: StateFlow = _currentUrl.asStateFlow() + + companion object { + private const val TAG = "AudioPlayService" + } + + init { + initializePlayer() + } + + /** + * 初始化播放器 + */ + private fun initializePlayer() { + if (exoPlayer == null) { + exoPlayer = ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_OFF // 默认单次播放,不循环 + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + LogUtils.d(TAG, "音频准备就绪") + } + Player.STATE_ENDED -> { + LogUtils.d(TAG, "音频播放结束") + _isPlaying.value = false + } + Player.STATE_BUFFERING -> { + LogUtils.d(TAG, "音频缓冲中") + } + Player.STATE_IDLE -> { + LogUtils.d(TAG, "音频空闲") + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + _isPlaying.value = isPlaying + LogUtils.d(TAG, "播放状态变化: $isPlaying") + } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + LogUtils.e(TAG, "音频播放错误: ${error.message}") + _isPlaying.value = false + } + }) + } + } + } + + /** + * 播放音频 + * @param audioUrl 音频URL + * @param forceRestart 是否强制重新开始(即使URL相同) + */ + fun play(audioUrl: String?, forceRestart: Boolean = false) { + if (audioUrl.isNullOrEmpty()) { + LogUtils.i(TAG, "音频URL为空,停止播放") + stop() + return + } + + val player = exoPlayer ?: run { + initializePlayer() + exoPlayer + } ?: return + + // 如果URL相同且不强制重启,则继续播放或恢复播放 + if (audioUrl == currentAudioUrl && !forceRestart) { + if (!player.isPlaying) { + player.play() + LogUtils.d(TAG, "恢复播放音频: $audioUrl") + } else { + LogUtils.d(TAG, "音频已在播放: $audioUrl") + } + return + } + + // URL不同或强制重启,切换音频 + LogUtils.d(TAG, "切换音频: $currentAudioUrl -> $audioUrl") + + try { + // 停止当前播放 + if (player.isPlaying) { + player.stop() + } + + // 设置新的音频 + val mediaItem = MediaItem.fromUri(Uri.parse(audioUrl)) + player.setMediaItem(mediaItem) + player.prepare() + + // 开始播放 + player.play() + + // 更新当前URL + currentAudioUrl = audioUrl + _currentUrl.value = audioUrl + + LogUtils.d(TAG, "开始播放音频: $audioUrl") + } catch (e: Exception) { + LogUtils.e(TAG, "播放音频失败: ${e.message}") + _isPlaying.value = false + } + } + + /** + * 暂停播放 + */ + fun pause() { + exoPlayer?.pause() + LogUtils.d(TAG, "暂停播放") + } + + /** + * 恢复播放 + */ + fun resume() { + exoPlayer?.play() + LogUtils.d(TAG, "恢复播放") + } + + /** + * 停止播放 + */ + fun stop() { + exoPlayer?.stop() + currentAudioUrl = null + _currentUrl.value = null + _isPlaying.value = false + LogUtils.d(TAG, "停止播放") + } + + /** + * 释放播放器资源 + */ + fun release() { + exoPlayer?.release() + exoPlayer = null + currentAudioUrl = null + _currentUrl.value = null + _isPlaying.value = false + LogUtils.d(TAG, "释放播放器资源") + } + + /** + * 设置音量 + * @param volume 音量值,范围 0.0f - 1.0f + */ + fun setVolume(volume: Float) { + exoPlayer?.volume = volume.coerceIn(0f, 1f) + } + + /** + * 设置循环播放模式 + * @param repeatMode 循环模式: + * - Player.REPEAT_MODE_OFF: 不循环(单次播放) + * - Player.REPEAT_MODE_ONE: 循环播放当前音频 + * - Player.REPEAT_MODE_ALL: 循环播放播放列表中的所有音频 + */ + fun setRepeatMode(repeatMode: Int) { + exoPlayer?.repeatMode = repeatMode + LogUtils.d(TAG, "设置循环模式: $repeatMode") + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt new file mode 100644 index 0000000..9bd5b7d --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt @@ -0,0 +1,228 @@ +package com.yzx.kiosk.audio + +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.yzx.kiosk.R +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.utils.LogUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 本地音频播放服务 + * 用于播放项目中的本地音频文件,根据页面路由自动播放对应的音频 + */ +@Singleton +class LocalAudioPlayService @Inject constructor( + private val context: Context +) { + private var exoPlayer: ExoPlayer? = null + private var currentAudioResId: Int? = null + + // 播放状态 + private val _isPlaying = MutableStateFlow(false) + val isPlaying: StateFlow = _isPlaying.asStateFlow() + + // 当前播放的音频资源ID + private val _currentResId = MutableStateFlow(null) + val currentResId: StateFlow = _currentResId.asStateFlow() + + companion object { + private const val TAG = "LocalAudioPlayService" + + /** + * 页面路由到音频资源ID的映射 + */ + private val ROUTE_TO_AUDIO_MAP = mapOf( + AppRoutes.HOME to R.raw.home_audio, + AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page, + AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page, + AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page, + AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page, + AppRoutes.PAY_SUCCESS to R.raw.pay_success_select_photo_print_page, + AppRoutes.PRINTING to R.raw.printing_page, + AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page, + // 特殊状态页面 + "face_recognition_recognizing" to R.raw.face_recognition_recognizing_page, + "face_recognition_failed" to R.raw.face_recognition_failed_page, + "upload_scan_success" to R.raw.upload_scan_success_page, + "upload_unpaid_result" to R.raw.upload_unpaid_result_page + ) + } + + init { + initializePlayer() + } + + /** + * 初始化播放器 + */ + private fun initializePlayer() { + if (exoPlayer == null) { + exoPlayer = ExoPlayer.Builder(context).build().apply { + repeatMode = Player.REPEAT_MODE_OFF // 单次播放,不循环 + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + LogUtils.d(TAG, "音频准备就绪") + } + Player.STATE_ENDED -> { + LogUtils.d(TAG, "音频播放结束") + _isPlaying.value = false + } + Player.STATE_BUFFERING -> { + LogUtils.d(TAG, "音频缓冲中") + } + Player.STATE_IDLE -> { + LogUtils.d(TAG, "音频空闲") + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + _isPlaying.value = isPlaying + LogUtils.d(TAG, "播放状态变化: $isPlaying") + } + + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + LogUtils.e(TAG, "音频播放错误: ${error.message}") + _isPlaying.value = false + } + }) + } + } + } + + /** + * 根据页面路由播放对应的音频 + * @param route 页面路由(可能包含参数,如 "face_recognition_result?results=...") + * @param forceRestart 是否强制重新开始(即使音频相同) + */ + fun playByRoute(route: String?, forceRestart: Boolean = false) { + if (route.isNullOrEmpty()) { + LogUtils.d(TAG, "路由为空,不播放音频") + return + } + + // 提取基础路由名称(去掉参数部分) + val baseRoute = route.substringBefore("?") + + val audioResId = ROUTE_TO_AUDIO_MAP[baseRoute] + if (audioResId == null) { + LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件") + return + } + + play(audioResId, forceRestart) + } + + /** + * 播放指定资源ID的音频 + * @param audioResId 音频资源ID + * @param forceRestart 是否强制重新开始(即使音频相同) + */ + fun play(audioResId: Int, forceRestart: Boolean = false) { + if (audioResId == 0) { + LogUtils.i(TAG, "音频资源ID无效,停止播放") + stop() + return + } + + val player = exoPlayer ?: run { + initializePlayer() + exoPlayer + } ?: return + + // 如果资源ID相同且不强制重启,则继续播放或恢复播放 + if (audioResId == currentAudioResId && !forceRestart) { + if (!player.isPlaying) { + player.play() + LogUtils.d(TAG, "恢复播放音频: $audioResId") + } else { + LogUtils.d(TAG, "音频已在播放: $audioResId") + } + return + } + + // 资源ID不同或强制重启,切换音频 + LogUtils.d(TAG, "切换音频: $currentAudioResId -> $audioResId") + + try { + // 停止当前播放 + if (player.isPlaying) { + player.stop() + } + + // 设置新的音频 + val mediaItem = MediaItem.fromUri("android.resource://${context.packageName}/$audioResId") + player.setMediaItem(mediaItem) + player.prepare() + + // 开始播放 + player.play() + + // 更新当前资源ID + currentAudioResId = audioResId + _currentResId.value = audioResId + + LogUtils.d(TAG, "开始播放音频: $audioResId") + } catch (e: Exception) { + LogUtils.e(TAG, "播放音频失败: ${e.message}") + _isPlaying.value = false + } + } + + /** + * 暂停播放 + */ + fun pause() { + exoPlayer?.pause() + LogUtils.d(TAG, "暂停播放") + } + + /** + * 恢复播放 + */ + fun resume() { + exoPlayer?.play() + LogUtils.d(TAG, "恢复播放") + } + + /** + * 停止播放 + */ + fun stop() { + exoPlayer?.stop() + currentAudioResId = null + _currentResId.value = null + _isPlaying.value = false + LogUtils.d(TAG, "停止播放") + } + + /** + * 释放播放器资源 + */ + fun release() { + exoPlayer?.release() + exoPlayer = null + currentAudioResId = null + _currentResId.value = null + _isPlaying.value = false + LogUtils.d(TAG, "释放播放器资源") + } + + /** + * 设置音量 + * @param volume 音量值,范围 0.0f - 1.0f + */ + fun setVolume(volume: Float) { + exoPlayer?.volume = volume.coerceIn(0f, 1f) + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/base/BaseViewModel.kt b/app/src/main/java/com/yzx/kiosk/base/BaseViewModel.kt new file mode 100644 index 0000000..6e307c2 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/base/BaseViewModel.kt @@ -0,0 +1,114 @@ +package com.yzx.kiosk.base + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavOptions +import com.yzx.kiosk.component.auth.GlobalAuthDialogManager +import com.yzx.kiosk.component.loading.GlobalLoadingManager +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.network.model.response.NetworkResponse +import com.yzx.kiosk.network.result.Result +import com.yzx.kiosk.network.result.ResultHandler +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + + +abstract class BaseViewModel( + protected val navigator: AppNavigator, + protected val appState: AppState, +) : ViewModel() { + // 公开 appState 以便在 Composable 中使用 + val publicAppState: AppState = appState + + fun navigateBack() { + viewModelScope.launch { + navigator.navigateBack() + } + } + + fun navigateBack(result: Map) { + viewModelScope.launch { + navigator.navigateBack(result) + } + } + + fun closeAllExcept(route: String) { + viewModelScope.launch { + navigator.closeAllExcept(route) + } + } + + fun toPage(route: String) { + viewModelScope.launch { + navigator.navigateTo(route) + } + } + + fun toPage(route: String, args: Map) { + viewModelScope.launch { + val fullRoute = buildRouteWithArgs(route, args) + navigator.navigateTo(fullRoute) + } + } + + fun toPage(route: String, navOptions: NavOptions) { + viewModelScope.launch { + navigator.navigateTo(route, navOptions) + } + } + + + private fun buildRouteWithArgs(baseRoute: String, args: Map): String { + if (args.isEmpty()) return baseRoute + + val argString = args.entries.joinToString("&") { (key, value) -> + // 对参数值进行 URL 编码,处理特殊字符和中文 + val encodedValue = URLEncoder.encode(value.toString(), StandardCharsets.UTF_8.name()) + "$key=$encodedValue" + } + + return if (baseRoute.contains("?")) { + "$baseRoute&$argString" + } else { + "$baseRoute?$argString" + } + } + + fun showLoading() { + GlobalLoadingManager.show() + } + + fun hideLoading() { + GlobalLoadingManager.hide() + } + + fun handleResultWithData( + flow: Flow>>, + showToast: Boolean = true, + onLoading: () -> Unit = {}, + onData: (T) -> Unit, + onDataResponse: (NetworkResponse) -> Unit = {}, + onError: (String, Throwable?) -> Unit = { _, _ -> }, + onEnd: () -> Unit = {}, + onProgress: (Int) -> Unit = {}, + ) { + ResultHandler.handleResultWithData( + scope = viewModelScope, + flow = flow, + showToast = showToast, + onLoading = onLoading, + onData = onData, + onError = onError, + onEnd = onEnd, + onDataResponse = onDataResponse, + onTokenFailed = { + onError("", null) + GlobalAuthDialogManager.show() + }, + onProgress = onProgress, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/appbar/AppStateManager.kt b/app/src/main/java/com/yzx/kiosk/component/appbar/AppStateManager.kt new file mode 100644 index 0000000..ae29eee --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/appbar/AppStateManager.kt @@ -0,0 +1,400 @@ +package com.yzx.kiosk.component.appbar + +import android.content.Context +import com.alibaba.fastjson.TypeReference +import com.alibaba.idst.nui.CommonUtils +import com.alibaba.idst.nui.Constants +import com.alibaba.idst.nui.INativeTtsCallback +import com.alibaba.idst.nui.NativeNui +import com.alibaba.nls.client.AccessToken +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.ResultHandler +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.status.ApplicationScope +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.SimplePcmPlayer +import com.yzx.kiosk.utils.ToastUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONException +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Base64 +import java.util.UUID +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject +import javax.inject.Singleton + + +/** + * 全局单例工具类,用于处理 TTS(文本转语音)功能 + * 注意:耗时操作在后台线程执行,避免阻塞主线程 + */ +@Singleton +class AppStateManager @Inject constructor( + private val appStore: AppStoreDataSource, + private val repository: NetWorkRepository, + @ApplicationContext private val context: Context, + @ApplicationScope private val applicationScope: CoroutineScope +) { + + private var aliyunToken: String? = null + private var ttsInstance: NativeNui? = null + private var ttsInited = false + + // 使用 lazy 延迟初始化,避免内存浪费 + private val pcmPlayer by lazy { SimplePcmPlayer() } + + companion object { + private const val TAG = "AppStateManager" + } + + private val ttsCallback = object : INativeTtsCallback { + override fun onTtsEventCallback( + event: INativeTtsCallback.TtsEvent, + p1: String, + p2: Int + ) { + taskID = p1 + when (event) { + INativeTtsCallback.TtsEvent.TTS_EVENT_START -> pcmPlayer.play() + INativeTtsCallback.TtsEvent.TTS_EVENT_END -> pcmPlayer.stop() + INativeTtsCallback.TtsEvent.TTS_EVENT_ERROR -> { + pcmPlayer.stop() + val error_msg: String? = ttsInstance?.getparamTts("error_msg") + LogUtils.i(TAG, "TTS_EVENT_ERROR error_code:" + p2 + " errmsg:" + error_msg) + } + + else -> {} + } + } + + override fun onTtsDataCallback(info: String, infoLen: Int, data: ByteArray) { + LogUtils.i(TAG, "onTtsDataCallback") + if (data.isNotEmpty()) { + LogUtils.i(TAG, "onTtsDataCallback size = " + data.size) + pcmPlayer.write(data) + } + } + + override fun onTtsVolCallback(p0: Int) { + LogUtils.i(TAG, "TTS音量 = $p0") + } + } + + init { + // 在后台线程预取 Token,避免阻塞主线程 + applicationScope.launch(Dispatchers.IO) { + prefetchStsToken() + } + } + + /** + * 确保 Token 存在后播放语音 + * 注意:耗时操作在后台线程执行 + */ + fun ensureTokenThenSpeak(voiceText: String) { + LogUtils.i(TAG, "voiceText:$voiceText") + if (!aliyunToken.isNullOrBlank()) { + // 已有 token,直接播放(在后台线程执行) + applicationScope.launch(Dispatchers.IO) { + speakPaySuccess(voiceText) + } + return + } + + // 在后台线程获取 Token + ResultHandler.handleResultWithData( + scope = applicationScope, + flow = repository.aliyunStsToken("facebox").asResult(), + showToast = false, + onData = { data -> + makeVoice( + data.credentials.security_token, + data.credentials.access_key_id, + data.credentials.access_key_secret + ) + + // 获取 Token 后播放语音(在后台线程执行) + applicationScope.launch(Dispatchers.IO) { + speakPaySuccess(voiceText) + } + }, + onError = { msg, _ -> + LogUtils.e(TAG, "获取阿里云 Token 失败: $msg") + } + ) + } + + var taskID = "" + + /** + * 播放语音(在后台线程执行) + */ + private suspend fun speakPaySuccess(voiceText: String) { + try { + ensureTtsInit() + if (!ttsInited || ttsInstance == null) { + LogUtils.e(TAG, "speakPaySuccess: tts not inited") + return + } + ttsInstance?.cancelTts(taskID) + ttsInstance?.startTts("1", "", voiceText) + } catch (e: Exception) { + LogUtils.e(TAG, "播放语音失败: ${e.message}") + e.printStackTrace() + pcmPlayer.stop() + } + } + + /** + * 确保 TTS 已初始化(在后台线程执行) + */ + private suspend fun ensureTtsInit() { + if (ttsInited && ttsInstance != null) return + + val token = aliyunToken + if (token.isNullOrBlank()) { + LogUtils.e(TAG, "ensureTtsInit: token is null, skip init") + return + } + + // 在后台线程执行耗时操作 + withContext(Dispatchers.IO) { + val workspace = CommonUtils.getModelPath(context) + LogUtils.i(TAG, "workspace = $workspace") + val isCopy = CommonUtils.copyAssetsData(context) + LogUtils.i(TAG, "copyAssetsData = $isCopy") + + val instance = NativeNui(Constants.ModeType.MODE_TTS) + + val ticket = genTicket(token, workspace) + + val ret = instance.tts_initialize( + ttsCallback, + ticket, + Constants.LogLevel.LOG_LEVEL_VERBOSE, + true + ) + + LogUtils.i(TAG, "tts_initialize ret = $ret") + if (Constants.NuiResultCode.SUCCESS == ret) { + ttsInstance = instance + ttsInited = true + } else { + ttsInstance = instance + ttsInited = false + // Toast 需要在主线程显示 + withContext(Dispatchers.Main) { + ToastUtils.show("语音合成组件初始化失败") + } + } + } + } + + /** + * ticket生成示例,详见Demo工程中代码示例 + */ + private fun genTicket(token: String, workPath: String): String { + var str = "" + try { + val map: HashMap = HashMap() + map["app_key"] = "OX2sCE6aO91M85bt" + map["token"] = token + map["workspace"] = workPath + map["url"] = "wss://nls-gateway.cn-shanghai.aliyuncs.com:443/ws/v1" + map["device_id"] = appStore.getDeviceSN() + map["mode_type"] = "2" + //map 转json字符串 + str = JSONObject(map).toString() + } catch (e: JSONException) { + e.printStackTrace() + } + return str + } + + /** + * 预取 STS Token(在后台线程执行) + */ + private suspend fun prefetchStsToken() { + ResultHandler.handleResultWithData( + scope = applicationScope, + flow = repository.aliyunStsToken("facebox").asResult(), + showToast = false, + onData = { data -> + LogUtils.i(TAG, "预取 ak 成功") + makeVoice( + data.credentials.security_token, + data.credentials.access_key_id, + data.credentials.access_key_secret + ) + }, + onError = { msg, _ -> + LogUtils.e(TAG, "预取 Token 失败: $msg") + } + ) + } + + /** + * 清理资源,防止内存泄漏 + * 注意:在应用退出时调用 + */ + fun cleanup() { + try { + pcmPlayer.stop() + ttsInstance?.release() + ttsInstance = null + ttsInited = false + aliyunToken = null + LogUtils.i(TAG, "资源清理完成") + } catch (e: Exception) { + LogUtils.e(TAG, "清理资源失败: ${e.message}") + } + } + + + fun makeVoice(stsSecurityToken: String, stsAccessKeyId: String, stsAccessKeySecret: String) { + + val nlsMetaEndpoint = "https://nls-meta.cn-shanghai.aliyuncs.com/" + val timestamp = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + .format(Instant.now()) + + val params = mutableMapOf( + "AccessKeyId" to stsAccessKeyId, + "Action" to "CreateToken", + "Format" to "JSON", + "RegionId" to "cn-shanghai", + "SignatureMethod" to "HMAC-SHA1", + "SignatureNonce" to uuidV4(), + "SignatureVersion" to "1.0", + "Timestamp" to timestamp, + "Version" to "2019-02-28", + "SecurityToken" to stsSecurityToken + ) + + val signature = popSign("GET", params, stsAccessKeySecret) + params["Signature"] = signature + + val createTokenUrl = nlsMetaEndpoint + "?" + buildPopQuery(params) + val createTokenResp = httpGetJson(createTokenUrl) + + val tokenObj = createTokenResp["Token"] as? Map<*, *> + val nlsToken = tokenObj?.get("Id") as? String + if (!nlsToken.isNullOrBlank()) { + LogUtils.i("aliyunToken = $aliyunToken") + aliyunToken = nlsToken + } + } + + fun uuidV4(): String = UUID.randomUUID().toString() + + fun percentEncode(str: String): String { + return URLEncoder.encode(str, StandardCharsets.UTF_8.name()) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~") + } + + fun buildPopQuery(params: Map): String { + return params.toSortedMap().entries.joinToString("&") { (k, v) -> + percentEncode(k) + "=" + percentEncode(v?.toString() ?: "") + } + } + + fun popSign(method: String, params: Map, accessKeySecret: String): String { + val canonicalized = params.toSortedMap().entries.joinToString("&") { (k, v) -> + percentEncode(k) + "=" + percentEncode(v?.toString() ?: "") + } + val stringToSign = + method.uppercase() + "&" + percentEncode("/") + "&" + percentEncode(canonicalized) + val key = accessKeySecret + "&" + + val mac = Mac.getInstance("HmacSHA1") + mac.init(SecretKeySpec(key.toByteArray(StandardCharsets.UTF_8), "HmacSHA1")) + val hmac = mac.doFinal(stringToSign.toByteArray(StandardCharsets.UTF_8)) + + return Base64.getEncoder().encodeToString(hmac) + } + + fun httpGetJson(url: String): Map { + + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + instanceFollowRedirects = true + connectTimeout = 30_000 + readTimeout = 30_000 + } + + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val body = stream?.readBytes()?.toString(Charsets.UTF_8) ?: "" + return try { + Gson().fromJson(body, object : TypeToken>() {}.type) + } catch (e: Exception) { + throw RuntimeException("HTTP $code non-JSON response: $body", e) + } finally { + conn.disconnect() + } + } + +// fun httpPostJsonRaw( +// url: String, +// jsonBody: Any, +// headers: List = emptyList() +// ): ByteArray { +// val payload = mapper.writeValueAsBytes(jsonBody) +// val conn = (URL(url).openConnection() as HttpURLConnection).apply { +// requestMethod = "POST" +// doOutput = true +// connectTimeout = 60_000 +// readTimeout = 60_000 +// headers.forEach { header -> +// val parts = header.split(":", limit = 2) +// if (parts.size == 2) { +// setRequestProperty(parts[0].trim(), parts[1].trim()) +// } else { +// setRequestProperty(header.trim(), "") +// } +// } +// } +// +// conn.outputStream.use { it.write(payload) } +// +// val code = conn.responseCode +// val stream = if (code in 200..299) conn.inputStream else conn.errorStream +// val resp = stream?.readBytes() ?: ByteArray(0) +// conn.disconnect() +// return resp +// } +// +// fun looksLikeJson(bytes: ByteArray): Boolean { +// var i = 0 +// while (i < bytes.size) { +// val b = bytes[i] +// if (b == 0x20.toByte() || b == 0x0A.toByte() || b == 0x0D.toByte() || b == 0x09.toByte()) { +// i++ +// } else { +// break +// } +// } +// if (i >= bytes.size) return false +// val c = bytes[i].toInt().toChar() +// return c == '{' || c == '[' +// } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/appbar/AppTitleBar.kt b/app/src/main/java/com/yzx/kiosk/component/appbar/AppTitleBar.kt new file mode 100644 index 0000000..efb9754 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/appbar/AppTitleBar.kt @@ -0,0 +1,112 @@ +package com.yzx.kiosk.component.appbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import com.yzx.kiosk.R +import com.yzx.kiosk.ext.debounced +import com.yzx.kiosk.theme.BorderLight +import com.yzx.kiosk.theme.TITLE_BAR_HEIGHT +import com.yzx.kiosk.theme.TextColor333 +import com.yzx.kiosk.utils.DebounceClickHandler + + +@Composable +fun AppTitleBar( + backgroundColor: Color = Color.White, + title: String = "", + isWhite: Boolean = false, + isShowBackIcon: Boolean = true, + onBackClick: () -> Unit = {}, + isShowButtonLine: Boolean = true, + rightView: @Composable () -> Unit = {}, +) { + val debounceHandler = remember { DebounceClickHandler(1000L) } + + Box( + modifier = Modifier + .background(backgroundColor) + .statusBarsPadding() + ) { + ConstraintLayout( + modifier = Modifier + .height(TITLE_BAR_HEIGHT) + .padding(start = 24.dp) + .fillMaxWidth() + ) { + val (menu, centerTitle, line, right) = createRefs() + if (isShowBackIcon) { + IconButton( + modifier = Modifier.size(27.dp) + .constrainAs(menu) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + onClick = { + onBackClick() + }.debounced(debounceHandler, key = "title_back") + ) { + Icon( + painter = painterResource(id = R.mipmap.icon_back_black), + contentDescription = "Back", + modifier = Modifier.size(27.dp), + tint = if (isWhite) Color.White else Color.Black + ) + } + } + if (title.isNotEmpty()) { + Text( + text = title, + modifier = Modifier.constrainAs(centerTitle) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + }, + color = if (isWhite) Color.White else Color.Black, + fontSize = 27.sp, + fontWeight = FontWeight.Medium + ) + } + Box( + modifier = Modifier + .constrainAs(right) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + .padding(end = 0.dp) + ) { + rightView() + } + if (isShowButtonLine) { + HorizontalDivider( + modifier = Modifier.constrainAs(line) { + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + }, + thickness = 0.5.dp, + color = BorderLight, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/appbar/AppTitleBarNoStatusBarPadding.kt b/app/src/main/java/com/yzx/kiosk/component/appbar/AppTitleBarNoStatusBarPadding.kt new file mode 100644 index 0000000..902aaf6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/appbar/AppTitleBarNoStatusBarPadding.kt @@ -0,0 +1,127 @@ +package com.yzx.kiosk.component.appbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import com.yzx.kiosk.R +import com.yzx.kiosk.ext.debounced +import com.yzx.kiosk.theme.BorderLight +import com.yzx.kiosk.theme.TITLE_BAR_HEIGHT +import com.yzx.kiosk.theme.TextColor333 +import com.yzx.kiosk.utils.DebounceClickHandler + + +@Composable +fun AppTitleBarNoStatusBarPadding( + backgroundColor: Color = Color.White, + title: String = "", + isWhite: Boolean = false, + isShowBackIcon: Boolean = true, + onBackClick: () -> Unit = {}, + isShowButtonLine: Boolean = true, + rightView: @Composable () -> Unit = {}, + leftIconResId: Int? = null, // 左侧图标资源ID,如果为null则不显示 +) { + val debounceHandler = remember { DebounceClickHandler(1000L) } + + Box( + modifier = Modifier + .background(backgroundColor) + ) { + ConstraintLayout( + modifier = Modifier + .heightIn(min = TITLE_BAR_HEIGHT) + .padding(start = 24.dp) + .fillMaxWidth() + ) { + val (menu, leftIcon, centerTitle, line, right) = createRefs() + if (isShowBackIcon) { + IconButton( + modifier = Modifier + .size(27.dp) + .constrainAs(menu) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + onClick = { + onBackClick() + }.debounced(debounceHandler, key = "title_back") + ) { + Icon( + painter = painterResource(id = R.mipmap.icon_back_black), + contentDescription = "Back", + modifier = Modifier.size(27.dp), + tint = if (isWhite) Color.White else Color.Black + ) + } + } + // 左侧图标(不点击,仅显示) + if (leftIconResId != null) { + Icon( + painter = painterResource(id = leftIconResId), + contentDescription = null, + modifier = Modifier + .constrainAs(leftIcon) { + start.linkTo(parent.start, margin = 16.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + .size(36.dp), + tint = if (isWhite) Color.White else Color.Black + ) + } + if (title.isNotEmpty()) { + Text( + text = title, + modifier = Modifier.constrainAs(centerTitle) { + start.linkTo( parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + .padding(16.dp), + color = if (isWhite) Color.White else TextColor333, + fontSize = 27.sp, + ) + } + Box( + modifier = Modifier + .constrainAs(right) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + .padding(end = 0.dp) + ) { + rightView() + } + if (isShowButtonLine) { + HorizontalDivider( + modifier = Modifier.constrainAs(line) { + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + }, + thickness = 0.5.dp, + color = BorderLight, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/appbar/FaceBarNoStatusBarPadding.kt b/app/src/main/java/com/yzx/kiosk/component/appbar/FaceBarNoStatusBarPadding.kt new file mode 100644 index 0000000..706143c --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/appbar/FaceBarNoStatusBarPadding.kt @@ -0,0 +1,52 @@ +package com.yzx.kiosk.component.appbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import com.yzx.kiosk.R +import com.yzx.kiosk.ext.debounced +import com.yzx.kiosk.theme.BorderLight +import com.yzx.kiosk.theme.TITLE_BAR_HEIGHT +import com.yzx.kiosk.theme.TextColor333 +import com.yzx.kiosk.utils.DebounceClickHandler + + +@Composable +fun FaceBarNoStatusBarPadding( + backgroundColor: Color = Color(0xFFFFE7E7), + title: String = "", +) { + Box( + modifier = Modifier + .heightIn(min = 36.dp) + .fillMaxWidth() + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = title, + fontWeight = FontWeight.Normal, + color = Color(0xFFEF4444), + fontSize = 15.sp, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/appbar/SharedBarNoStatusBarPadding.kt b/app/src/main/java/com/yzx/kiosk/component/appbar/SharedBarNoStatusBarPadding.kt new file mode 100644 index 0000000..9b99d5a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/appbar/SharedBarNoStatusBarPadding.kt @@ -0,0 +1,130 @@ +package com.yzx.kiosk.component.appbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import com.yzx.kiosk.R +import com.yzx.kiosk.ext.debounced +import com.yzx.kiosk.theme.BorderLight +import com.yzx.kiosk.theme.TITLE_BAR_HEIGHT +import com.yzx.kiosk.theme.TextColor333 +import com.yzx.kiosk.utils.DebounceClickHandler + + +@Composable +fun SharedBarNoStatusBarPadding( + backgroundColor: Color = Color.White, + title: String = "", + isWhite: Boolean = false, + isShowBackIcon: Boolean = true, + onBackClick: () -> Unit = {}, + isShowButtonLine: Boolean = true, + rightView: @Composable () -> Unit = {}, + leftIconResId: Int? = null, // 左侧图标资源ID,如果为null则不显示 +) { + val debounceHandler = remember { DebounceClickHandler(1000L) } + + Box( + modifier = Modifier + .background(backgroundColor) + ) { + ConstraintLayout( + modifier = Modifier + .heightIn(min = 36.dp) + .fillMaxWidth() + ) { + val (menu, leftIcon, centerTitle, line, right) = createRefs() + if (isShowBackIcon) { + IconButton( + modifier = Modifier.constrainAs(menu) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + }, + onClick = { + onBackClick() + }.debounced(debounceHandler, key = "title_back") + ) { + Icon( + painter = painterResource(id = R.mipmap.icon_back_black), + contentDescription = "Back", + modifier = Modifier.size(24.dp), + tint = if (isWhite) Color.White else Color.Black + ) + } + } + // 左侧图标(不点击,仅显示) + if (leftIconResId != null) { + Icon( + painter = painterResource(id = leftIconResId), + contentDescription = null, + modifier = Modifier + .constrainAs(leftIcon) { + start.linkTo(parent.start, margin = 16.dp) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + .size(24.dp), + tint = if (isWhite) Color.White else Color.Black + ) + } + if (title.isNotEmpty()) { + Text( + text = title, + fontWeight = FontWeight.Medium, + modifier = Modifier.constrainAs(centerTitle) { + start.linkTo( + if (leftIconResId != null) leftIcon.end else parent.start, + margin = if (leftIconResId != null) 12.dp else 0.dp + ) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + end.linkTo(parent.end) + } + .padding(16.dp), + color = if (isWhite) Color.White else TextColor333, + fontSize = 16.sp, + ) + } + Box( + modifier = Modifier + .constrainAs(right) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + .padding(end = 0.dp) + ) { + rightView() + } + if (isShowButtonLine) { + HorizontalDivider( + modifier = Modifier.constrainAs(line) { + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + }, + thickness = 0.5.dp, + color = BorderLight, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/appbar/SharedPrintingStatusBar.kt b/app/src/main/java/com/yzx/kiosk/component/appbar/SharedPrintingStatusBar.kt new file mode 100644 index 0000000..7de7a8a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/appbar/SharedPrintingStatusBar.kt @@ -0,0 +1,56 @@ +package com.yzx.kiosk.component.appbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import com.yzx.kiosk.R +import com.yzx.kiosk.datastore.AppState + +/** + * 共享的打印状态栏 + * 在所有页面显示,内容和逻辑同步 + * + * @param appState AppState 实例,通常从 ViewModel 中获取 + * @param isShowBackIcon 是否显示返回按钮,默认为 false + */ +@Composable +fun SharedPrintingStatusBar( + appState: AppState, + isShowBackIcon: Boolean = false, + onBackClick: () -> Unit = {} +) { + val printingStatus by appState.printingStatus.collectAsState() + + // 根据打印状态消息判断背景颜色、文字颜色和左侧图标 + val statusText = printingStatus.ifEmpty { "当前无小程序用户操作打印。打印时,不影响当前屏幕操作" } + val statusBackgroundColor = when { + statusText.contains("打印失败") -> Color(0xFFE53935) // 红色 - 打印失败 + statusText.contains("打印完成") -> Color(0xFF4CAF50) // 绿色 - 打印完成 + statusText.contains("正在打印中") -> Color(0xFF2196F3) // 蓝色 - 正在打印中 + else -> Color(0xFFE3F2FD) // 浅蓝色 - 默认/空闲状态 + } + // 根据背景颜色判断文字颜色 + val statusTextColor = when { + statusText.contains("打印失败") || statusText.contains("正在打印中") || statusText.contains("打印完成") -> Color.White + else -> Color(0xFF333333) // 深灰色 - 默认状态文字颜色 + } + // 根据状态判断左侧图标资源ID + val leftIconResId: Int? = when { + statusText.contains("打印失败") -> R.mipmap.ic_top_print_err // 打印失败 + statusText.contains("打印完成") -> R.mipmap.ic_top_print_end // 打印完成 + statusText.contains("正在打印中") -> R.mipmap.ic_top_printting // 正在打印中 + else -> null // 无打印任务,不显示图标 + } + + SharedBarNoStatusBarPadding( + backgroundColor = statusBackgroundColor, + title = statusText, + isWhite = statusTextColor == Color.White, + isShowBackIcon = isShowBackIcon, + onBackClick = onBackClick, + isShowButtonLine = false, + leftIconResId = leftIconResId + ) +} + diff --git a/app/src/main/java/com/yzx/kiosk/component/auth/GlobalAuthDialogManager.kt b/app/src/main/java/com/yzx/kiosk/component/auth/GlobalAuthDialogManager.kt new file mode 100644 index 0000000..3916926 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/auth/GlobalAuthDialogManager.kt @@ -0,0 +1,128 @@ +package com.yzx.kiosk.component.auth + +import android.os.Handler +import android.os.Looper +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.yzx.kiosk.R +import com.yzx.kiosk.component.button.AppButton +import com.yzx.kiosk.theme.TextColor333 + +object GlobalAuthDialogManager { + + var isShown = mutableStateOf(false) + private set + + /** 防多次并发展示 */ + @Volatile + private var isShowingFlag = false + + /** 主线程安全展示(可在拦截器里直接调用) */ + fun show() { + val doShow = { + if (!isShowingFlag) { + isShowingFlag = true + isShown.value = true + } + } + if (Looper.myLooper() == Looper.getMainLooper()) { + doShow() + } else { + Handler(Looper.getMainLooper()).post { doShow() } + } + } + + fun dismiss() { + isShown.value = false + isShowingFlag = false + } +} + +@Composable +fun AuthExpiredDialog( + onConfirm: () -> Unit +) { + Dialog( + onDismissRequest = { /* 空实现,阻止点击外部消失 */ }, + properties = DialogProperties( + dismissOnBackPress = false, // 禁止返回键消失 + dismissOnClickOutside = false, // 禁止点击外部消失 + usePlatformDefaultWidth = true, + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + Icon( + painter = painterResource(id = R.mipmap.icon_log_out_warning), + contentDescription = null, + tint = Color(0xFFF05A5A), + modifier = Modifier.size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "登录提醒", + color = TextColor333, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "当前用户已在其他设备登录,\n请重新登录", + color = Color(0xFF4B5563), + fontSize = 14.sp, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + AppButton( + modifier = Modifier + .fillMaxWidth() + .height(46.dp), + text = "确认并退出登录", + textSize = 14.sp, + buttonRadius = 12.dp, + onClick = onConfirm, + ) + } + } +} + +@Preview +@Composable +private fun AuthExpiredDialogPreview() { + AuthExpiredDialog( + onConfirm = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/button/AppButton.kt b/app/src/main/java/com/yzx/kiosk/component/button/AppButton.kt new file mode 100644 index 0000000..0b62de9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/button/AppButton.kt @@ -0,0 +1,59 @@ +package com.yzx.kiosk.component.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.ext.debounced +import com.yzx.kiosk.theme.Primary +import com.yzx.kiosk.utils.DebounceClickHandler + + +@Composable +fun AppButton( + modifier: Modifier = Modifier, + buttonColor: Color = Primary, + buttonRadius: Dp = 10.dp, + text: String, + textSize: TextUnit = 17.sp, + textColor: Color = Color.White, + fontWeight: FontWeight? = null, + onClick: () -> Unit, + enable: Boolean = true, + border: BorderStroke? = null +) { + val debounceHandler = remember { DebounceClickHandler() } + + Button( + onClick = { + onClick() + }.debounced(debounceHandler, key = "AppButton"), + enabled = enable, + shape = RoundedCornerShape(buttonRadius), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, + disabledContainerColor = Color(0xFFC0C0C0) + ), + modifier = modifier, + border = border, + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = text, + fontSize = textSize, + color = textColor, + fontWeight = fontWeight, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/button/VerificationCodeButton.kt b/app/src/main/java/com/yzx/kiosk/component/button/VerificationCodeButton.kt new file mode 100644 index 0000000..323da80 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/button/VerificationCodeButton.kt @@ -0,0 +1,83 @@ +package com.yzx.kiosk.component.button + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.ext.debounced +import com.yzx.kiosk.theme.Primary +import com.yzx.kiosk.utils.DebounceClickHandler + +/** + * 可复用的验证码按钮组件 + * + * @param countdown 当前倒计时秒数,0表示未开始倒计时 + * @param initialText 初始文本,默认为"获取验证码" + * @param onClick 点击事件 + * @param modifier 修饰符 + * @param buttonColor 按钮颜色 + * @param buttonRadius 按钮圆角 + * @param textSize 文字大小 + * @param textColor 文字颜色 + * @param fontWeight 文字粗细 + * @param border 边框 + */ +@Composable +fun VerificationCodeButton( + countdown: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + initialText: String = "获取验证码", + buttonColor: Color = Primary, + buttonRadius: Dp = 10.dp, + textSize: TextUnit = 14.sp, + textColor: Color = Color.White, + fontWeight: FontWeight? = FontWeight.Medium, + border: BorderStroke? = null +) { + val debounceHandler = remember { DebounceClickHandler() } + + // 计算按钮显示的文本 + val buttonText = if (countdown > 0) { + "${countdown}秒" + } else { + initialText + } + + // 倒计时中禁用按钮 + val isEnabled = countdown == 0 + + Button( + onClick = { + onClick() + }.debounced(debounceHandler, key = "VerificationCodeButton"), + enabled = isEnabled, + shape = RoundedCornerShape(buttonRadius), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, + disabledContainerColor = buttonColor.copy(alpha = 0.7f) + ), + modifier = modifier.height(46.dp), + border = border, + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = buttonText, + fontSize = textSize, + color = textColor, + fontWeight = fontWeight, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/checkbox/CustomCheckbox.kt b/app/src/main/java/com/yzx/kiosk/component/checkbox/CustomCheckbox.kt new file mode 100644 index 0000000..186f6df --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/checkbox/CustomCheckbox.kt @@ -0,0 +1,50 @@ +package com.yzx.kiosk.component.checkbox + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.Image + +@Composable +fun CustomCheckbox( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + @DrawableRes checkedIconRes: Int, + @DrawableRes uncheckedIconRes: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, + size: Dp = 20.dp, + tint: androidx.compose.ui.graphics.Color? = null, // 传null表示使用图标原色 + contentDescription: String? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val painter = painterResource(id = if (checked) checkedIconRes else uncheckedIconRes) + + Image( + painter = painter, + contentDescription = contentDescription, + modifier = modifier + .size(size) + .semantics { + stateDescription = if (checked) "已选中" else "未选中" + } + .toggleable( + value = checked, + onValueChange = onCheckedChange, + enabled = enabled, + role = Role.Checkbox, + interactionSource = interactionSource, + indication = null + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/checkbox/IosSwitch.kt b/app/src/main/java/com/yzx/kiosk/component/checkbox/IosSwitch.kt new file mode 100644 index 0000000..b87ecef --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/checkbox/IosSwitch.kt @@ -0,0 +1,81 @@ +package com.yzx.kiosk.component.checkbox + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun IosSwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + // iOS 常见尺寸 + val trackWidth = 44.dp + val trackHeight = 24.dp + val thumbSize = 20.dp + val horizontalPadding = (trackHeight - thumbSize) / 2 + + // 轨道颜色 + val trackColor by animateColorAsState( + targetValue = when { + !enabled -> Color(0xFFCCCCCC) + checked -> Color(0xFF007AFF) // iOS 蓝 + else -> Color(0xFFE5E5EA) // iOS 灰 + }, + label = "trackColor" + ) + + // 小圆点位置 + val thumbOffsetX by animateDpAsState( + targetValue = if (checked) { + trackWidth - thumbSize - horizontalPadding + } else { + horizontalPadding + }, + label = "thumbOffset" + ) + + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = modifier + .size(trackWidth, trackHeight) + .clip(RoundedCornerShape(trackHeight / 2)) + .background(trackColor) + .clickable( + enabled = enabled, + indication = null, // 不要水波纹,更接近 iOS + interactionSource = interactionSource + ) { + onCheckedChange(!checked) + }, + contentAlignment = Alignment.CenterStart + ) { + // 拇指(白色圆点) + Box( + modifier = Modifier + .offset(x = thumbOffsetX) + .size(thumbSize) + .shadow(1.dp, CircleShape, clip = false) + .background(Color.White, CircleShape) + ) + } +} diff --git a/app/src/main/java/com/yzx/kiosk/component/dialog/Dialog.kt b/app/src/main/java/com/yzx/kiosk/component/dialog/Dialog.kt new file mode 100644 index 0000000..6b0dda1 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/dialog/Dialog.kt @@ -0,0 +1,254 @@ +package com.yzx.kiosk.component.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.yzx.kiosk.theme.Primary + +/** + * 对话框 + * + * @param title 标题 + * @param content 内容 + * @param okText 确定按钮文字 + * @param cancelText 取消按钮文字 + * @param okColor 确定按钮颜色 + * @param onOk 确定事件 + * @param onCancel 取消事件 + * @param onDismiss 关闭事件 + */ +@Composable +fun WeDialog( + title: String, + content: String? = null, + okText: String = "确定", + cancelText: String = "取消", + okColor: Color = Primary, + onOk: () -> Unit, + onCancel: (() -> Unit)? = null, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false + ) + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .fillMaxWidth(0.8f) + .background(MaterialTheme.colorScheme.surface) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + modifier = Modifier + .fillMaxWidth() + .padding( + top = 32.dp, + bottom = if (content != null) 16.dp else 0.dp, + start = 24.dp, + end = 24.dp + ), + fontSize = 17.sp, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + if (content != null) { + Text( + text = content, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + fontSize = 17.sp, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.height(32.dp)) + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outline) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (onCancel != null) { + Box( + modifier = Modifier + .weight(1f) + .height(56.dp) + .clickable(onClick = onCancel), + contentAlignment = Alignment.Center + ) { + Text( + text = cancelText, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) + } + Box( + modifier = Modifier + .size(0.5.dp, 56.dp) + .background(MaterialTheme.colorScheme.outline) + ) + } + Box( + modifier = Modifier + .weight(1f) + .height(56.dp) + .clickable(onClick = onOk), + contentAlignment = Alignment.Center + ) { + Text( + text = okText, + color = okColor, + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +@Stable +interface DialogState { + /** + * 是否显示 + */ + val visible: Boolean + + /** + * 显示对话框 + */ + fun show( + title: String, + content: String? = null, + okText: String = "确定", + cancelText: String = "取消", + okColor: Color = Primary, + closeOnAction: Boolean = true, + onCancel: (() -> Unit)? = {}, + onOk: (() -> Unit)? = null + ) + + /** + * 隐藏对话框 + */ + fun hide() +} + +@Composable +fun rememberDialogState(): DialogState { + val state = remember { DialogStateImpl() } + + if (state.visible) { + state.props?.let { props -> + WeDialog( + title = props.title, + content = props.content, + okText = props.okText, + cancelText = props.cancelText, + okColor = props.okColor, + onOk = { + props.onOk?.invoke() + if (props.closeOnAction) { + state.hide() + } + }, + onCancel = if (props.onCancel != null) { + { + props.onCancel.invoke() + if (props.closeOnAction) { + state.hide() + } + } + } else null, + onDismiss = { + state.hide() + } + ) + } + } + + return state +} + +private class DialogStateImpl : DialogState { + override var visible by mutableStateOf(false) + var props by mutableStateOf(null) + private set + + override fun show( + title: String, + content: String?, + okText: String, + cancelText: String, + okColor: Color, + closeOnAction: Boolean, + onCancel: (() -> Unit)?, + onOk: (() -> Unit)? + ) { + props = DialogProps( + title, + content, + okText, + cancelText, + okColor, + closeOnAction, + onCancel, + onOk + ) + visible = true + } + + override fun hide() { + visible = false + } +} + +private data class DialogProps( + val title: String, + val content: String?, + val okText: String, + val cancelText: String, + val okColor: Color, + val closeOnAction: Boolean, + val onCancel: (() -> Unit)?, + val onOk: (() -> Unit)? +) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/icon/List.kt b/app/src/main/java/com/yzx/kiosk/component/icon/List.kt new file mode 100644 index 0000000..bdb12c7 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/icon/List.kt @@ -0,0 +1,55 @@ +package com.yzx.kiosk.component.icon + +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +public val ListIcon: ImageVector + get() { + if (_list != null) { + return _list!! + } + _list = materialIcon(name = "Filled.List") { + materialPath { + moveTo(3.0f, 13.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-2.0f) + lineTo(3.0f, 11.0f) + verticalLineToRelative(2.0f) + close() + moveTo(3.0f, 17.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-2.0f) + lineTo(3.0f, 15.0f) + verticalLineToRelative(2.0f) + close() + moveTo(3.0f, 9.0f) + horizontalLineToRelative(2.0f) + lineTo(5.0f, 7.0f) + lineTo(3.0f, 7.0f) + verticalLineToRelative(2.0f) + close() + moveTo(7.0f, 13.0f) + horizontalLineToRelative(14.0f) + verticalLineToRelative(-2.0f) + lineTo(7.0f, 11.0f) + verticalLineToRelative(2.0f) + close() + moveTo(7.0f, 17.0f) + horizontalLineToRelative(14.0f) + verticalLineToRelative(-2.0f) + lineTo(7.0f, 15.0f) + verticalLineToRelative(2.0f) + close() + moveTo(7.0f, 7.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(14.0f) + lineTo(21.0f, 7.0f) + lineTo(7.0f, 7.0f) + close() + } + } + return _list!! + } + +private var _list: ImageVector? = null diff --git a/app/src/main/java/com/yzx/kiosk/component/icon/ShowChart.kt b/app/src/main/java/com/yzx/kiosk/component/icon/ShowChart.kt new file mode 100644 index 0000000..2f12701 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/icon/ShowChart.kt @@ -0,0 +1,28 @@ +package com.yzx.kiosk.component.icon + +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.ui.graphics.vector.ImageVector + +val ShowChartIcon: ImageVector + get() { + if (_showChart != null) { + return _showChart!! + } + _showChart = materialIcon(name = "Filled.ShowChart") { + materialPath { + moveTo(3.5f, 18.49f) + lineToRelative(6.0f, -6.01f) + lineToRelative(4.0f, 4.0f) + lineTo(22.0f, 6.92f) + lineToRelative(-1.41f, -1.41f) + lineToRelative(-7.09f, 7.97f) + lineToRelative(-4.0f, -4.0f) + lineTo(2.0f, 16.99f) + close() + } + } + return _showChart!! + } + +private var _showChart: ImageVector? = null diff --git a/app/src/main/java/com/yzx/kiosk/component/loading/LoadingController.kt b/app/src/main/java/com/yzx/kiosk/component/loading/LoadingController.kt new file mode 100644 index 0000000..75df786 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/loading/LoadingController.kt @@ -0,0 +1,102 @@ +package com.yzx.kiosk.component.loading + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import com.yzx.kiosk.R + +object GlobalLoadingManager { + var isLoading = mutableStateOf(false) + private set + + fun show() { + isLoading.value = true + } + + fun hide() { + isLoading.value = false + } +} + +@Composable +fun GlobalLoadingHost(content: @Composable () -> Unit) { + val isLoading by GlobalLoadingManager.isLoading + LoadingView(isLoading, content) +} + +@Composable +fun LoadingView( + isLoading: Boolean = false, + content: @Composable () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + content() + + if (isLoading) { + BackHandler(enabled = true) { + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.2f)) + .clickable( + enabled = true, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) {}, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(100.dp) + .clip(RoundedCornerShape(15.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + LottieLoading() + } + } + } + } +} + +@Composable +fun LottieLoading( + size: Dp = 80.dp, +) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading)) + LottieAnimation( + composition, + iterations = LottieConstants.IterateForever, + modifier = Modifier.size(size), + ) +} + +@Preview +@Composable +fun LoadingPreview() { + LoadingView(isLoading = true) { + + } +} diff --git a/app/src/main/java/com/yzx/kiosk/component/modal/BottomModal.kt b/app/src/main/java/com/yzx/kiosk/component/modal/BottomModal.kt new file mode 100644 index 0000000..e50ec20 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/modal/BottomModal.kt @@ -0,0 +1,173 @@ +package com.yzx.kiosk.component.modal + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.theme.BorderLight +import kotlinx.coroutines.launch + +/** + * 底部弹出层Modal组件 + * + * 基于Material3的ModalBottomSheet封装,提供统一的样式和行为。 + * + * @param visible 是否显示 + * @param title 标题,如果为null则不显示标题 + * @param onDismiss 关闭回调 + * @param sheetState 底部弹出层状态 + * @param showDragIndicator 是否显示顶部拖动指示器 + * @param horizontalPadding 水平内边距 + * @param containerColor 容器背景色 + * @param shape 容器形状 + * @param titleStyle 标题样式 + * @param content 内容 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomModal( + visible: Boolean, + title: String? = null, + onDismiss: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), + showDragIndicator: Boolean = true, + containerColor: Color = MaterialTheme.colorScheme.surface, + shape: Shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + titleStyle: TextStyle = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + color = Color.Black, + ), + content: @Composable ColumnScope.() -> Unit +) { + // 添加协程作用域用于动画控制 + val coroutineScope = rememberCoroutineScope() + + // 监听visible变化,处理关闭动画 + LaunchedEffect(visible) { + if (!visible && sheetState.isVisible) { + coroutineScope.launch { + sheetState.hide() + } + } + } + + // 计算安全区域 + val density = LocalDensity.current + val configuration = LocalConfiguration.current + + // 获取屏幕高度(dp) + val screenHeightDp = configuration.screenHeightDp.dp + + // 获取顶部安全区域高度(状态栏和刘海屏) + val statusBarHeightPx = WindowInsets.statusBars.getTop(density) + val cutoutHeightPx = WindowInsets.displayCutout.getTop(density) + val topInsetDp = with(density) { + maxOf(statusBarHeightPx, cutoutHeightPx).toDp() + } + + // 获取底部安全区域高度(导航栏) + val bottomInsetDp = with(density) { + WindowInsets.navigationBars.getBottom(density).toDp() + } + + // 计算安全内容区域高度 + // 屏幕高度 - 顶部安全区域 - 底部安全区域 - 额外安全边距(36dp) + val maxSafeContentHeight = screenHeightDp - topInsetDp - bottomInsetDp - 36.dp + + if (visible || sheetState.isVisible) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = containerColor, + shape = shape, + // 关闭系统默认的拖动指示器 + dragHandle = { + if (showDragIndicator) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 15.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(40.dp, 4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.8f)) + ) + } + } + } + ) { + Column( + modifier = Modifier + .wrapContentHeight() + // 使用计算出的安全区域高度 + .heightIn(max = maxSafeContentHeight) + .animateContentSize() + ) { + // 标题 + title?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = it, + style = titleStyle, + ) + } + } + + HorizontalDivider( + thickness = 0.5.dp, + color = BorderLight, + ) + + // 内容 + content() + + // 底部间距,确保内容不会被系统导航栏遮挡 + Spacer(modifier = Modifier.height(12.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/modal/Modal.kt b/app/src/main/java/com/yzx/kiosk/component/modal/Modal.kt new file mode 100644 index 0000000..aca2a96 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/modal/Modal.kt @@ -0,0 +1,131 @@ +package com.yzx.kiosk.component.modal + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +/** + * Modal类型枚举 + */ +enum class ModalType { + /** + * 底部弹出模式 + */ + BOTTOM, + + /** + * 居中弹出模式 + */ + CENTER +} + +/** + * 通用Modal组件 + * + * 支持底部弹出和居中弹出两种模式 + * + * @param visible 是否显示 + * @param modalType 弹出类型,默认为底部弹出 + * @param onDismiss 关闭回调 + * @param dismissOnBackPress 是否允许返回键关闭 + * @param dismissOnClickOutside 是否允许点击外部关闭 + * @param content 内容 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Modal( + visible: Boolean, + modalType: ModalType = ModalType.BOTTOM, + onDismiss: () -> Unit, + dismissOnBackPress: Boolean = true, + dismissOnClickOutside: Boolean = true, + content: @Composable ColumnScope.() -> Unit +) { + var localVisible by remember { mutableStateOf(false) } + + LaunchedEffect(visible) { + localVisible = visible + } + + if (visible || localVisible) { + when (modalType) { + ModalType.BOTTOM -> { + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp), + dragHandle = null + ) { + content() + } + } + + ModalType.CENTER -> { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = dismissOnBackPress, + dismissOnClickOutside = dismissOnClickOutside + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + AnimatedVisibility( + visible = visible && localVisible, + enter = fadeIn(animationSpec = tween(200)) + + slideInVertically( + animationSpec = tween(200), + initialOffsetY = { it / 2 } + ), + exit = fadeOut(animationSpec = tween(200)) + + slideOutVertically( + animationSpec = tween(200), + targetOffsetY = { it / 2 } + ) + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(12.dp) + ) + ) { + content() + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/component/scaffold/AppScaffold.kt b/app/src/main/java/com/yzx/kiosk/component/scaffold/AppScaffold.kt new file mode 100644 index 0000000..17a2ff6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/component/scaffold/AppScaffold.kt @@ -0,0 +1,73 @@ +package com.yzx.kiosk.component.scaffold + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +/** + * 应用通用Scaffold组件 + * + * 该组件封装了Material3 Scaffold,使用CenterTopAppBar作为顶部应用栏, + * 提供了一个统一的页面框架结构,简化常见页面布局的创建。 + * 默认使用Box布局并处理安全区域padding。 + * + * @param modifier 应用于Scaffold的修饰符 + * @param title 顶部应用栏标题的资源ID,如果为null则不显示标题 + * @param topBarColors 顶部应用栏的颜色配置 + * @param topBarActions 顶部应用栏右侧的操作按钮区域 + * @param showBackIcon 是否显示返回按钮,默认为true + * @param onBackClick 点击返回按钮时的回调函数 + * @param snackbarHostState Snackbar宿主状态 + * @param backgroundColor 页面背景颜色,默认为 MaterialTheme 的 surface 颜色 + * @param bottomBar 底部导航栏的内容,默认为null + * @param floatingActionButton 浮动操作按钮的内容,默认为null + * @param content 页面主体内容,接收PaddingValues参数以适应顶部应用栏的空间 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppScaffold( + modifier: Modifier = Modifier, + title: Int? = null, + topBar: @Composable () -> Unit = {}, + topBarColors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), + topBarActions: @Composable (RowScope.() -> Unit) = {}, + showBackIcon: Boolean = true, + onBackClick: () -> Unit = {}, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + backgroundColor: Color = MaterialTheme.colorScheme.background, + bottomBar: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + topBar = topBar, + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = bottomBar, + floatingActionButton = floatingActionButton, + modifier = modifier, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .padding(paddingValues) + ) { + content(paddingValues) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/datastore/AppState.kt b/app/src/main/java/com/yzx/kiosk/datastore/AppState.kt new file mode 100644 index 0000000..38b176a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/datastore/AppState.kt @@ -0,0 +1,94 @@ +package com.yzx.kiosk.datastore + +import com.yzx.kiosk.component.appbar.AppStateManager +import com.yzx.kiosk.status.ApplicationScope +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.websocket.PrivacyPrintService +import dagger.Lazy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class AppState @Inject constructor( + private val appStore: AppStoreDataSource, + private val privacyPrintService: PrivacyPrintService, + @ApplicationScope private val applicationScope: CoroutineScope, + private val appStateManager: Lazy // 使用 Lazy 避免潜在的循环依赖 +) { + + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn: StateFlow = _isLoggedIn.asStateFlow() + + // 打印状态文本(全局共享) + private val _printingStatus = MutableStateFlow("当前无小程序用户操作打印。打印时,不影响当前屏幕操作") + val printingStatus: StateFlow = _printingStatus.asStateFlow() + + // 剩余打印张数(全局共享) + private val _remainingPaperNum = MutableStateFlow(0) + val remainingPaperNum: StateFlow = _remainingPaperNum.asStateFlow() + + // 延迟显示默认消息的任务 + private var defaultMessageJob: Job? = null + + /** + * 更新剩余打印张数 + */ + fun updateRemainingPaperNum(num: Int) { + _remainingPaperNum.value = num + } + + fun initialize() { + applicationScope.launch { + initializeState() + } + } + + private fun initializeState() { + _isLoggedIn.value = appStore.isLoggedIn() + + // 初始化剩余打印张数(从本地读取) + _remainingPaperNum.value = appStore.getRemainingPaperNum() + + // 监听打印状态通知 + applicationScope.launch { + privacyPrintService.printNotification.collect { notification -> + LogUtils.d("消息更新 : " + notification) + if (notification != null) { + // 取消之前的延迟任务(如果有) + defaultMessageJob?.cancel() + defaultMessageJob = null + + if (notification.contains("当前无小程序用户操作打印")) { + defaultMessageJob = applicationScope.launch { + delay(8000) // 延迟8秒 + _printingStatus.value = notification + } + } else { + // 立即显示通知消息 + _printingStatus.value = notification + // 调用 TTS 播放语音 + appStateManager.get().ensureTokenThenSpeak(notification) + } + } else { + // 队列为空时,延迟3秒后显示默认消息,确保打印成功和失败的消息最少显示3秒钟 + defaultMessageJob?.cancel() + defaultMessageJob = applicationScope.launch { + delay(3000) // 延迟3秒 + val defaultMessage = "当前无小程序用户操作打印。打印时,不影响当前屏幕操作" + _printingStatus.value = defaultMessage +// appStateManager.get().ensureTokenThenSpeak(defaultMessage) + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/datastore/AppStateModule.kt b/app/src/main/java/com/yzx/kiosk/datastore/AppStateModule.kt new file mode 100644 index 0000000..5a104cf --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/datastore/AppStateModule.kt @@ -0,0 +1,27 @@ +package com.yzx.kiosk.status + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppStateModule { + + @ApplicationScope + @Singleton + @Provides + fun providesApplicationScope(): CoroutineScope { + return CoroutineScope(SupervisorJob() + Dispatchers.Default) + } +} + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt b/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt new file mode 100644 index 0000000..372ccf9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt @@ -0,0 +1,538 @@ +package com.yzx.kiosk.datastore + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.provider.Settings +import com.yzx.kiosk.App +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.MMKVUtils +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppStoreDataSource @Inject constructor() { + + companion object { + private const val TAG = "AppStoreDataSource" + + private const val KEY_IN_TOKEN = "key_in_token" + private const val KEY_IN_USER_ID = "key_in_user_id" + private const val KEY_IN_USER_NAME = "key_in_user_name" + private const val KEY_IN_REAL_NAME = "key_in_real_name" + private const val KEY_IN_AVATAR = "key_in_avatar" + private const val KEY_IN_PHONE = "key_in_phone" + private const val KEY_IN_ROLE_NAME = "key_in_role_name" + private const val KEY_IN_ROLE_ID = "key_in_role_id" + private const val KEY_IN_PERMISSION = "key_in_permission" + private const val KEY_IN_CURRENT_SCENIC_ID = "key_in_current_scenic_id" + private const val KEY_IN_CURRENT_SCENIC_NAME = "key_in_current_scenic_name" + private const val KEY_IN_IS_FIRST_USED = "key_in_is_first_used" + + private const val KEY_COMMON_FUNCTION_IDS = "key_common_function_ids" + + private const val KEY_COMMON_FUNCTION_URIS = "key_common_function_uris" + + private const val KEY_LAST_LOCATION_REPORT_TIME = "key_last_location_report_time" + private const val KEY_ONLINE_STATUS = "key_online_status" + private const val KEY_ROLE_PERMISSION_LIST = "key_role_permission_list" + private const val KEY_LOCATION_REMINDER_MINUTES = "key_location_reminder_minutes" + private const val KEY_CURRENT_ROLE_SCENIC_LIST = "key_current_role_scenic_list" + + //收款是否开启语音播报 + private const val KEY_IS_OPEN_RECEIVE_VOICE = "key_is_open_receive_voice" + + // 打印机ID + private const val KEY_PRINT_ID = "PRINT_ID" + + // 设备配置 + private const val KEY_DEVICE_SN = "device_sn" + private const val KEY_DEVICE_SECRET = "device_secret" + private const val KEY_SCENIC_ID = "scenic_id" + private const val KEY_SCENIC_NAME = "scenic_name" + private const val KEY_SERVICE_PHONE = "service_phone" + private const val KEY_POSTER_LIST = "poster_list" + private const val KEY_ALERT_SOUND = "alert_sound" + private const val KEY_DEVICE_NAME = "device_name" + private const val KEY_TECH_SUPPORT_PHONE = "tech_support_phone" + private const val KEY_BGM = "bgm" + /** 支付成功页「打印」按钮图,来自设备配置 global_config.button_print */ + private const val KEY_BUTTON_PRINT_URL = "button_print_url" + private const val KEY_BIND_BOX_URL = "bind_box_url" + private const val KEY_BIND_BOX_LAN_URL = "bind_box_lan_url" + private const val KEY_BIND_BOX_SN = "bind_box_sn" + private const val KEY_BIND_BOX_API_TOKEN = "bind_box_api_token" + private const val KEY_BIND_BOX_LAN_IP = "bind_box_lan_ip" + private const val KEY_SOCKET_TOKEN = "socket_token" + private const val KEY_SOCKET_TOKEN_EXPIRED_AT = "socket_token_expired_at" + private const val KEY_FACE_SEARCH_TOKEN = "face_search_token" + private const val KEY_USE_LAN = "use_lan" + private const val KEY_REMAINING_PAPER_NUM = "remaining_paper_num" + private const val KEY_PRINT_RECORDS = "print_records" + private const val KEY_SHOW_WECHAT_SCAN_PRIVACY = "show_wechat_scan_privacy" + /** 首页是否开启直播(仅本地,默认关闭) */ + private const val KEY_HOME_PAGE_LIVE_ENABLED = "home_page_live_enabled" + /** 是否使用新首页样式(仅本地,默认关闭) */ + private const val KEY_HOME_NEW_STYLE_ENABLED = "home_new_style_enabled" + /** 景区直播海报 play_urls.auto(WebSocket + /api/oscar/lives 下发) */ + private const val KEY_POSTER_SCENIC_LIVE_RTMP_URL = "poster_scenic_live_rtmp_url" + /** 旧版扩展开关键名,首次读取时迁移至新键 */ + private const val LEGACY_PRIVACY_SUB_1 = "privacy_wechat_scan_sub_1" + private const val LEGACY_PRIVACY_SUB_2 = "privacy_wechat_scan_sub_2" + private const val KEY_HOMEPAGE_SERVICE_PHONE = "homepage_service_phone" + } + + + fun isLoggedIn(): Boolean { + return getToken().isNotEmpty() + } + + fun getToken(): String { + return MMKVUtils.getString(KEY_IN_TOKEN) + } + + fun saveToken(token: String) { + MMKVUtils.putString(KEY_IN_TOKEN, token) + } + + fun getUserId(): String { + return MMKVUtils.getString(KEY_IN_USER_ID) + } + + fun saveUserId(userId: String) { + MMKVUtils.putString(KEY_IN_USER_ID, userId) + } + + fun getUserName(): String { + return MMKVUtils.getString(KEY_IN_USER_NAME) + } + + fun saveUserName(userName: String) { + MMKVUtils.putString(KEY_IN_USER_NAME, userName) + } + + fun getUserPhone(): String { + return MMKVUtils.getString(KEY_IN_PHONE) + } + + fun saveUserPhone(phone: String) { + MMKVUtils.putString(KEY_IN_PHONE, phone) + } + + fun getUserRoleName(): String { + return MMKVUtils.getString(getUserId() + KEY_IN_ROLE_NAME) + } + + fun saveUserRoleName(roleName: String) { + MMKVUtils.putString(getUserId() + KEY_IN_ROLE_NAME, roleName) + } + + fun getUserRoleId(): Int { + return MMKVUtils.getInt(getUserId() + KEY_IN_ROLE_ID, 0) + } + + fun saveUserRoleId(roleId: Int) { + MMKVUtils.putInt(getUserId() + KEY_IN_ROLE_ID, roleId) + } + + fun getRealName(): String { + return MMKVUtils.getString(KEY_IN_REAL_NAME) + } + + fun saveRealName(realName: String) { + MMKVUtils.putString(KEY_IN_REAL_NAME, realName) + } + + fun getAvatar(): String { + return MMKVUtils.getString(KEY_IN_AVATAR) + } + + fun saveAvatar(avatar: String) { + MMKVUtils.putString(KEY_IN_AVATAR, avatar) + } + + fun getCurrentScenicId(): String { + return MMKVUtils.getString(getUserId() + KEY_IN_CURRENT_SCENIC_ID) + } + + fun saveCurrentScenicId(currentScenicId: String) { + MMKVUtils.putString(getUserId() + KEY_IN_CURRENT_SCENIC_ID, currentScenicId) + } + + fun getCurrentScenicName(): String { + return MMKVUtils.getString(getUserId() + KEY_IN_CURRENT_SCENIC_NAME) + } + + fun saveCurrentScenicName(currentScenicName: String) { + MMKVUtils.putString(getUserId() + KEY_IN_CURRENT_SCENIC_NAME, currentScenicName) + } + + fun isFirstUsed(): Boolean { + return MMKVUtils.getBoolean(KEY_IN_IS_FIRST_USED, true) + } + + fun saveIsFirstUsed(isFirstUsed: Boolean) { + MMKVUtils.putBoolean(KEY_IN_IS_FIRST_USED, isFirstUsed) + } + + fun saveLastLocationReportTime(timestamp: Long) { + MMKVUtils.putLong(getUserId() + KEY_LAST_LOCATION_REPORT_TIME, timestamp) + } + + fun getLastLocationReportTime(): Long { + return MMKVUtils.getLong(getUserId() + KEY_LAST_LOCATION_REPORT_TIME, 0L) + } + + fun clearLastLocationReportTime() { + MMKVUtils.remove(getUserId() + KEY_LAST_LOCATION_REPORT_TIME) + } + + fun saveOnlineStatus(isOnline: Boolean) { + MMKVUtils.putBoolean(getUserId() + KEY_ONLINE_STATUS, isOnline) + } + + fun getOnlineStatus(): Boolean { + return MMKVUtils.getBoolean(getUserId() + KEY_ONLINE_STATUS, false) + } + + fun saveLocationReminderMinutes(minutes: Int) { + MMKVUtils.putInt(getUserId() + KEY_LOCATION_REMINDER_MINUTES, minutes) + } + + fun getLocationReminderMinutes(): Int { + return MMKVUtils.getInt(getUserId() + KEY_LOCATION_REMINDER_MINUTES, 0) + } + + fun getIsOpenReceiveVoice(): Boolean { + return MMKVUtils.getBoolean(getUserId() + KEY_IS_OPEN_RECEIVE_VOICE, false) + } + + fun setIsOpenReceiveVoice(isOpen: Boolean) { + MMKVUtils.putBoolean(getUserId() + KEY_IS_OPEN_RECEIVE_VOICE, isOpen) + } + + fun savePrintId(printId: Int) { + MMKVUtils.putInt(KEY_PRINT_ID, printId) + } + + fun getPrintId(): Int { + return MMKVUtils.getInt(KEY_PRINT_ID, 0) + } + + // 设备SN + fun saveDeviceSN(sn: String) { + MMKVUtils.putString(KEY_DEVICE_SN, sn) + } + + fun getDeviceSN(): String { + val savedSN = MMKVUtils.getString(KEY_DEVICE_SN) + // 如果本地存储的SN为空,则返回设备序列号作为默认值 + return if (savedSN.isNotEmpty()) { + savedSN + } else { + getDeviceSerialNumber() + } + } + + /** 设备密钥(用于请求签名 token) */ + fun saveDeviceSecret(secret: String) { + MMKVUtils.putString(KEY_DEVICE_SECRET, secret) + } + + fun getDeviceSecret(): String { + return MMKVUtils.getString(KEY_DEVICE_SECRET) + } + + /** + * 获取设备序列号 + */ + @SuppressLint("HardwareIds") + private fun getDeviceSerialNumber(): String { + return try { + Settings.Secure.getString(App.instance.contentResolver, Settings.Secure.ANDROID_ID) ?: "" + } catch (e: Exception) { + LogUtils.e(TAG, "获取设备SN失败: ${e.message}") + "" + } + } + + // 景区ID + fun saveScenicId(id: String) { + MMKVUtils.putString(KEY_SCENIC_ID, id) + } + + fun getScenicId(): String { + return MMKVUtils.getString(KEY_SCENIC_ID) + } + + // 景区名称 + fun saveScenicName(name: String) { + MMKVUtils.putString(KEY_SCENIC_NAME, name) + } + + fun getScenicName(): String { + return MMKVUtils.getString(KEY_SCENIC_NAME) + } + + // 客服电话 + fun saveServicePhone(phone: String) { + MMKVUtils.putString(KEY_SERVICE_PHONE, phone) + } + + fun getServicePhone(): String { + return MMKVUtils.getString(KEY_SERVICE_PHONE) + } + + // 海报列表 + fun savePosterList(list: List) { + MMKVUtils.putList(KEY_POSTER_LIST, list) + } + + fun getPosterList(): List { + return MMKVUtils.getList(KEY_POSTER_LIST) ?: emptyList() + } + + // 提示音 + fun saveAlertSound(url: String) { + MMKVUtils.putString(KEY_ALERT_SOUND, url) + } + + fun getAlertSound(): String { + return MMKVUtils.getString(KEY_ALERT_SOUND) + } + + // 设备名称 + fun saveDeviceName(name: String) { + MMKVUtils.putString(KEY_DEVICE_NAME, name) + } + + fun getDeviceName(): String { + return MMKVUtils.getString(KEY_DEVICE_NAME) + } + + // 技术支持电话 + fun saveTechSupportPhone(phone: String) { + MMKVUtils.putString(KEY_TECH_SUPPORT_PHONE, phone) + } + + fun getTechSupportPhone(): String { + return MMKVUtils.getString(KEY_TECH_SUPPORT_PHONE) + } + + // BGM欢迎语音 + fun saveBgm(url: String) { + MMKVUtils.putString(KEY_BGM, url) + } + + fun getBgm(): String { + return MMKVUtils.getString(KEY_BGM) + } + + fun saveButtonPrintUrl(url: String) { + MMKVUtils.putString(KEY_BUTTON_PRINT_URL, url) + } + + fun getButtonPrintUrl(): String { + return MMKVUtils.getString(KEY_BUTTON_PRINT_URL) + } + + // 绑定盒子URL + fun saveBindBoxUrl(url: String) { + MMKVUtils.putString(KEY_BIND_BOX_URL, url) + } + + fun getBindBoxUrl(): String { + return MMKVUtils.getString(KEY_BIND_BOX_URL) + } + + // 绑定盒子局域网URL + fun saveBindBoxLanUrl(url: String) { + MMKVUtils.putString(KEY_BIND_BOX_LAN_URL, url) + } + + fun getBindBoxLanUrl(): String { + return MMKVUtils.getString(KEY_BIND_BOX_LAN_URL) + } + + // Socket Token + fun saveSocketToken(token: String) { + MMKVUtils.putString(KEY_SOCKET_TOKEN, token) + } + + fun getSocketToken(): String { + return MMKVUtils.getString(KEY_SOCKET_TOKEN) + } + + // Socket Token 过期时间 + fun saveSocketTokenExpiredAt(expiredAt: Long) { + MMKVUtils.putLong(KEY_SOCKET_TOKEN_EXPIRED_AT, expiredAt) + } + + fun getSocketTokenExpiredAt(): Long { + return MMKVUtils.getLong(KEY_SOCKET_TOKEN_EXPIRED_AT, 0L) + } + + // 绑定盒子局域网IP + fun saveBindBoxLanIp(ip: String) { + MMKVUtils.putString(KEY_BIND_BOX_LAN_IP, ip) + } + + fun getBindBoxLanIp(): String { + return MMKVUtils.getString(KEY_BIND_BOX_LAN_IP) + } + + // 绑定盒子SN + fun saveBindBoxSn(sn: String) { + MMKVUtils.putString(KEY_BIND_BOX_SN, sn) + } + + fun getBindBoxSn(): String { + return MMKVUtils.getString(KEY_BIND_BOX_SN) + } + + // 绑定盒子API Token + fun saveBindBoxApiToken(token: String) { + MMKVUtils.putString(KEY_BIND_BOX_API_TOKEN, token) + } + + fun getBindBoxApiToken(): String { + return MMKVUtils.getString(KEY_BIND_BOX_API_TOKEN) + } + + // Face Search Token (使用 bind_box_api_token) + fun saveFaceSearchToken(token: String) { + MMKVUtils.putString(KEY_BIND_BOX_API_TOKEN, token) + } + + fun getFaceSearchToken(): String { + return MMKVUtils.getString(KEY_BIND_BOX_API_TOKEN) + } + + // 是否使用LAN + fun saveUseLan(useLan: Boolean) { + MMKVUtils.putBoolean(KEY_USE_LAN, useLan) + } + + fun getUseLan(): Boolean { + return MMKVUtils.getBoolean(KEY_USE_LAN, false) + } + + /** + * 保存打印纸张数 + */ + fun saveRemainingPaperNum(num: Int) { + MMKVUtils.putInt(KEY_REMAINING_PAPER_NUM, num) + } + + /** + * 获取打印纸张数,如果没有则返回0 + */ + fun getRemainingPaperNum(): Int { + return MMKVUtils.getInt(KEY_REMAINING_PAPER_NUM, 0) + } + + /** + * 保存打印记录列表 + */ + fun savePrintRecords(records: List) { + MMKVUtils.putList(KEY_PRINT_RECORDS, records) + } + + /** + * 获取打印记录列表,如果没有则返回空列表 + */ + fun getPrintRecords(): List { + return MMKVUtils.getList(KEY_PRINT_RECORDS) ?: emptyList() + } + + /** + * 添加一条打印记录(追加到列表头部,最多保存1000条) + */ + fun addPrintRecord(record: com.yzx.kiosk.priter.PrintRecord) { + val currentRecords = getPrintRecords().toMutableList() + currentRecords.add(0, record) // 添加到列表头部 + // 最多保存1000条记录 + val recordsToSave = if (currentRecords.size > 1000) { + currentRecords.take(1000) + } else { + currentRecords + } + savePrintRecords(recordsToSave) + } + + /** + * 保存首页微信扫码隐私取片开关状态 + */ + fun saveShowWechatScanPrivacy(show: Boolean) { + MMKVUtils.putBoolean(KEY_SHOW_WECHAT_SCAN_PRIVACY, show) + } + + /** + * 获取首页微信扫码隐私取片开关状态,默认为 true(显示) + */ + fun getShowWechatScanPrivacy(): Boolean { + return MMKVUtils.getBoolean(KEY_SHOW_WECHAT_SCAN_PRIVACY, true) + } + + fun saveHomePageLiveEnabled(enabled: Boolean) { + MMKVUtils.putBoolean(KEY_HOME_PAGE_LIVE_ENABLED, enabled) + } + + fun getHomePageLiveEnabled(): Boolean { + if (!MMKVUtils.containsKey(KEY_HOME_PAGE_LIVE_ENABLED)) { + val legacy = if (MMKVUtils.containsKey(LEGACY_PRIVACY_SUB_1)) { + MMKVUtils.getBoolean(LEGACY_PRIVACY_SUB_1, false) + } else { + false + } + MMKVUtils.putBoolean(KEY_HOME_PAGE_LIVE_ENABLED, legacy) + if (MMKVUtils.containsKey(LEGACY_PRIVACY_SUB_1)) { + MMKVUtils.remove(LEGACY_PRIVACY_SUB_1) + } + return legacy + } + return MMKVUtils.getBoolean(KEY_HOME_PAGE_LIVE_ENABLED, false) + } + + fun saveHomeNewStyleEnabled(enabled: Boolean) { + MMKVUtils.putBoolean(KEY_HOME_NEW_STYLE_ENABLED, enabled) + } + + fun getHomeNewStyleEnabled(): Boolean { + if (!MMKVUtils.containsKey(KEY_HOME_NEW_STYLE_ENABLED)) { + val legacy = if (MMKVUtils.containsKey(LEGACY_PRIVACY_SUB_2)) { + MMKVUtils.getBoolean(LEGACY_PRIVACY_SUB_2, false) + } else { + false + } + MMKVUtils.putBoolean(KEY_HOME_NEW_STYLE_ENABLED, legacy) + if (MMKVUtils.containsKey(LEGACY_PRIVACY_SUB_2)) { + MMKVUtils.remove(LEGACY_PRIVACY_SUB_2) + } + return legacy + } + return MMKVUtils.getBoolean(KEY_HOME_NEW_STYLE_ENABLED, false) + } + + fun savePosterScenicLiveRtmpUrl(url: String) { + MMKVUtils.putString(KEY_POSTER_SCENIC_LIVE_RTMP_URL, url) + } + + fun getPosterScenicLiveRtmpUrl(): String { + return MMKVUtils.getString(KEY_POSTER_SCENIC_LIVE_RTMP_URL) + } + + /** + * 保存首页客服电话 + */ + fun saveHomepageServicePhone(phone: String) { + MMKVUtils.putString(KEY_HOMEPAGE_SERVICE_PHONE, phone) + } + + /** + * 获取首页客服电话 + */ + fun getHomepageServicePhone(): String { + return MMKVUtils.getString(KEY_HOMEPAGE_SERVICE_PHONE) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSourceModule.kt b/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSourceModule.kt new file mode 100644 index 0000000..87cc818 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSourceModule.kt @@ -0,0 +1,16 @@ +package com.yzx.kiosk.datastore + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class AppStoreDataSourceModule { + + @Provides + @Singleton + fun provideAuthStoreDataSource(): AppStoreDataSource = AppStoreDataSource() +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/docs/socket-token-api-call-sites.md b/app/src/main/java/com/yzx/kiosk/docs/socket-token-api-call-sites.md new file mode 100644 index 0000000..1db5864 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/docs/socket-token-api-call-sites.md @@ -0,0 +1,43 @@ +# /api/oscar/socket-token 接口调用时机与逻辑 + +## 一、接口说明 + +Socket Token 是 WebSocket 连接的认证 token,**不是短期 token**。仅在 WebSocket 需要连接时获取,连接成功后不刷新。 + +## 二、实际发起 HTTP 请求的位置(共 3 处) + +| # | 位置 | 方法 | 触发时机 | 后续动作 | +|---|------|------|----------|----------| +| 1 | **MainActivity** | `loadSocketToken()` | Activity 启动时 `onCreate()` 调用一次 | 成功则保存 token、启动 WebSocket 连接 | +| 2 | **WebSocketService** | `refreshTokenAndBind()` | 收到 WebSocket 服务端消息 **code = "100099"**(Token 无效)时 | 成功则保存、仅重新发送绑定消息 `sendTokenBindMessage()`,不重连 | +| 3 | **WebSocketService** | `fetchTokenAndStart()` | ① **连接断开/失败**时:`scheduleReconnect()` 延迟后调用
② **心跳包响应超时**时:`startHeartbeatTimeout()` 内调用 | 成功则保存、调用 `start()` 重新建连并绑定 | + +--- + +## 三、调用链简图 + +``` +MainActivity.onCreate() + └─ loadSocketToken() ──────────────────────────────────► 调用接口 + └─ 成功 → 保存 token → webSocketService.start() + +WebSocket 连接断开/失败 (onClosed / onFailure) + └─ scheduleReconnect() → delay → fetchTokenAndStart() ──► 调用接口 + └─ 成功 → start() 重新连接 + +心跳包响应超时 + └─ fetchTokenAndStart() ───────────────────────────────► 调用接口 + └─ 成功 → start() 重新连接 + +WebSocket 收到 code=100099(Token 无效) + └─ refreshTokenAndBind() ──────────────────────────────► 调用接口 + └─ 成功 → sendTokenBindMessage()(仅重新绑定,不重连) +``` + +--- + +## 四、设计原则 + +- **连接时获取**:每次 WebSocket 需要建立连接时(启动或重连)才调用 socket-token 接口 +- **连接中不刷新**:连接成功后,连接保持期间不刷新 token +- **100099 特殊处理**:服务端告知 token 无效时,获取新 token 并重新发送绑定消息,连接不断开 diff --git a/app/src/main/java/com/yzx/kiosk/ext/Click.kt b/app/src/main/java/com/yzx/kiosk/ext/Click.kt new file mode 100644 index 0000000..ff852f5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ext/Click.kt @@ -0,0 +1,30 @@ +package com.yzx.kiosk.ext + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.yzx.kiosk.utils.DebounceClickHandler + +fun (() -> Unit).debounced( + debounceHandler: DebounceClickHandler, + key: String = "default" +): () -> Unit = { + if (debounceHandler.canClick(key)) { + this() + } +} + +@Composable +fun Modifier.debounceClickable( + key: String = "default", + debounceHandler: DebounceClickHandler = DebounceClickHandler(), + onClick: () -> Unit +): Modifier = this.then( + Modifier.clickable { + if (debounceHandler.canClick(key)) { + onClick() + } + } +) + + diff --git a/app/src/main/java/com/yzx/kiosk/ext/Context.kt b/app/src/main/java/com/yzx/kiosk/ext/Context.kt new file mode 100644 index 0000000..3ca820e --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ext/Context.kt @@ -0,0 +1,14 @@ +package com.yzx.kiosk.ext + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +fun Context.findActivity(): Activity? { + var ctx = this + while (ctx is ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null +} diff --git a/app/src/main/java/com/yzx/kiosk/ext/Modifier.kt b/app/src/main/java/com/yzx/kiosk/ext/Modifier.kt new file mode 100644 index 0000000..b198b9e --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ext/Modifier.kt @@ -0,0 +1,33 @@ +package com.yzx.kiosk.ext + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +fun Modifier.dashedBorder( + color: Color, + strokeWidth: Dp, + cornerRadius: Dp, + dashLength: Dp = 10.dp, + gapLength: Dp = 5.dp +): Modifier = this.then( + Modifier.drawBehind { + val stroke = Stroke( + width = strokeWidth.toPx(), + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(dashLength.toPx(), gapLength.toPx()), 0f + ) + ) + drawRoundRect( + color = color, + size = Size(size.width, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius.toPx()), + style = stroke + ) + } +) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt new file mode 100644 index 0000000..19c1899 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt @@ -0,0 +1,247 @@ +package com.yzx.kiosk.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.utils.InactivityManager +import com.yzx.kiosk.ui.device.view.DeviceConfigScreen +import com.yzx.kiosk.ui.home.view.HomeScreen +import com.yzx.kiosk.ui.printer.view.PrinterManageScreen +import com.yzx.kiosk.ui.setting.view.SettingScreen +import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen +import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen +import com.yzx.kiosk.ui.upload.view.PaySuccessScreen +import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen +import com.yzx.kiosk.ui.upload.view.PrintingScreen +import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.yzx.kiosk.network.model.response.FaceSearchResult +import com.yzx.kiosk.ui.setting.navigation.agreementScreen +import kotlinx.coroutines.flow.collectLatest +import java.net.URLDecoder +import java.net.URLEncoder + +@Composable +fun AppNavHost( + navigator: AppNavigator, + startDestination: String, + isHomeVideoPlaying: Boolean = true, + inactivityManager: InactivityManager? = null +) { + val navController = rememberNavController() + + // 监听当前路由变化 + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentBackStackEntry?.destination?.route + + LaunchedEffect(navController) { + navigator.navigationEvents.collectLatest { event -> + navController.handleNavigationEvent(event) + } + } + + // 监听路由变化,更新首页状态 + LaunchedEffect(currentRoute) { + if (inactivityManager != null && currentRoute != null) { + val isHome = currentRoute == AppRoutes.HOME || currentRoute.startsWith(AppRoutes.HOME + "?") + inactivityManager.setOnHomePage(isHome) + } + } + + NavHost( + navController = navController, + startDestination = startDestination, + // 页面进入动画 + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300) + ) + }, + // 页面退出动画 + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300) + ) + }, + // 返回时页面进入动画 + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300) + ) + }, + // 返回时页面退出动画 + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300) + ) + } + ) { + // 首页 + composable(route = AppRoutes.HOME) { + HomeScreen(isVideoPlaying = isHomeVideoPlaying) + } + + // 设置页面 + composable(route = AppRoutes.SETTINGS) { + SettingScreen() + } + + // 打印机管理页面 + composable(route = AppRoutes.PRINTER_MANAGE) { + PrinterManageScreen() + } + + // 设备配置页面 + composable(route = AppRoutes.DEVICE_CONFIG) { + DeviceConfigScreen() + } + + // 上传照片打印页面 + composable(route = AppRoutes.UPLOAD_PHOTO) { + UploadPhotoScreen() + } + + // 照片选择页面 + composable(route = "${AppRoutes.PHOTO_SELECT}?urls={urls}") { backStackEntry -> + val urlsParam = backStackEntry.arguments?.getString("urls") ?: "" + val photoUrls = if (urlsParam.isNotEmpty()) { + try { + val decoded = URLDecoder.decode(urlsParam, "UTF-8") + // 如果是 JSON 格式(以 [ 开头),作为单个元素传递 + if (decoded.startsWith("[")) { + listOf(decoded) + } else { + // 旧格式:逗号分隔的 URL 列表 + decoded.split(",") + } + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + PhotoSelectScreen(photoUrls = photoUrls) + } + + // 人脸识别页面 + composable(route = AppRoutes.FACE_RECOGNITION) { + FaceRecognitionScreen() + } + + // 人脸识别结果页面 + composable(route = "${AppRoutes.FACE_RECOGNITION_RESULT}?results={results}") { backStackEntry -> + val resultsParam = backStackEntry.arguments?.getString("results") ?: "" + val results = if (resultsParam.isNotEmpty()) { + try { + val decoded = URLDecoder.decode(resultsParam, "UTF-8") + val type = object : TypeToken>() {}.type + Gson().fromJson>(decoded, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + FaceRecognitionResultScreen(results = results) + } + + // 支付成功页面 + composable(route = "${AppRoutes.PAY_SUCCESS}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry -> + val urlsParam = backStackEntry.arguments?.getString("urls") ?: "" + val orderNumberParam = backStackEntry.arguments?.getString("orderNumber") ?: "" + val captureTypeParam = backStackEntry.arguments?.getString("captureType") ?: "" + + val photoUrls = if (urlsParam.isNotEmpty()) { + try { + val decoded = URLDecoder.decode(urlsParam, "UTF-8") + // 如果是 JSON 格式(以 [ 开头),作为单个元素传递 + if (decoded.startsWith("[")) { + listOf(decoded) + } else { + // 旧格式:逗号分隔的 URL 列表 + decoded.split(",") + } + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + + val orderNumber = if (orderNumberParam.isNotEmpty()) { + try { + URLDecoder.decode(orderNumberParam, "UTF-8") + } catch (e: Exception) { + "" + } + } else { + "" + } + + val captureType = captureTypeParam.toIntOrNull() + + PaySuccessScreen( + photoUrls = photoUrls, + orderNumber = orderNumber, + captureType = captureType + ) + } + + // 打印中页面 + composable(route = "${AppRoutes.PRINTING}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry -> + val urlsParam = backStackEntry.arguments?.getString("urls") ?: "" + val orderNumberParam = backStackEntry.arguments?.getString("orderNumber") ?: "" + val captureTypeParam = backStackEntry.arguments?.getString("captureType") ?: "" + + val photoUrls = if (urlsParam.isNotEmpty()) { + try { + val decoded = URLDecoder.decode(urlsParam, "UTF-8") + // 如果是 JSON 格式(以 [ 开头),作为单个元素传递 + if (decoded.startsWith("[")) { + listOf(decoded) + } else { + // 旧格式:逗号分隔的 URL 列表 + decoded.split(",") + } + } catch (e: Exception) { + emptyList() + } + } else { + emptyList() + } + + val orderNumber = if (orderNumberParam.isNotEmpty()) { + try { + URLDecoder.decode(orderNumberParam, "UTF-8") + } catch (e: Exception) { + "" + } + } else { + "" + } + + val captureType = captureTypeParam.toIntOrNull() + + PrintingScreen( + photoUrls = photoUrls, + orderNumber = orderNumber, + captureType = captureType + ) + } + + // 协议页面 + agreementScreen() + } +} diff --git a/app/src/main/java/com/yzx/kiosk/navigation/AppNavigator.kt b/app/src/main/java/com/yzx/kiosk/navigation/AppNavigator.kt new file mode 100644 index 0000000..495f282 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/navigation/AppNavigator.kt @@ -0,0 +1,75 @@ +package com.yzx.kiosk.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavOptions +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import com.yzx.kiosk.navigation.routes.AppRoutes +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class AppNavigator @Inject constructor() { + private val _navigationEvents = MutableSharedFlow() + val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + + suspend fun navigateTo(route: String, navOptions: NavOptions? = null) { + _navigationEvents.emit(NavigationEvent.NavigateTo(route, navOptions)) + } + + + + suspend fun navigateBack() { + _navigationEvents.emit(NavigationEvent.NavigateBack()) + } + + suspend fun navigateBack(result: Map) { + _navigationEvents.emit(NavigationEvent.NavigateBack(result)) + } + + suspend fun navigateBackTo(route: String, inclusive: Boolean = false) { + _navigationEvents.emit(NavigationEvent.NavigateBackTo(route, inclusive)) + } + + suspend fun closeAllExcept(route: String) { + _navigationEvents.emit(NavigationEvent.CloseAllExcept(route)) + } +} + +fun NavController.handleNavigationEvent(event: NavigationEvent) { + when (event) { + is NavigationEvent.NavigateTo -> { + this.navigate(event.route, event.navOptions) + } + + is NavigationEvent.NavigateBack -> { + val homeRoute = AppRoutes.HOME + val currentRoute = this.currentDestination?.route + + if (currentRoute != null && currentRoute != homeRoute) { + event.result?.let { resultMap -> + this.previousBackStackEntry?.savedStateHandle?.apply { + resultMap.forEach { (key, value) -> set(key, value) } + } + } + this.popBackStack() + } + } + + is NavigationEvent.NavigateBackTo -> { + this.popBackStack(event.route, event.inclusive) + } + + is NavigationEvent.CloseAllExcept -> { + val graphId = this.graph.id + this.navigate(event.route) { + popUpTo(graphId) { + inclusive = true + } + launchSingleTop = true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/navigation/NavigationEvent.kt b/app/src/main/java/com/yzx/kiosk/navigation/NavigationEvent.kt new file mode 100644 index 0000000..8fc613b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/navigation/NavigationEvent.kt @@ -0,0 +1,25 @@ +package com.yzx.kiosk.navigation + +import androidx.navigation.NavOptions + + +sealed class NavigationEvent { + + data class NavigateTo( + val route: String, + val navOptions: NavOptions? = null + ) : NavigationEvent() + + data class NavigateBack( + val result: Map? = null + ) : NavigationEvent() + + data class NavigateBackTo( + val route: String, + val inclusive: Boolean = false + ) : NavigationEvent() + + data class CloseAllExcept( + val route: String + ) : NavigationEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt new file mode 100644 index 0000000..bfc4edd --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt @@ -0,0 +1,17 @@ +package com.yzx.kiosk.navigation.routes + +object AppRoutes { + const val HOME = "home" + const val SETTINGS = "settings" + const val PRINTER_MANAGE = "printer_manage" + const val DEVICE_CONFIG = "device_config" + const val UPLOAD_PHOTO = "upload_photo" + const val PHOTO_SELECT = "photo_select" + const val PAY_SUCCESS = "pay_success" + const val PRINTING = "printing" + const val PRINT_SUCCESS = "print_success" + const val FACE_RECOGNITION = "face_recognition" + const val FACE_RECOGNITION_RESULT = "face_recognition_result" + + const val AGREEMENT = "agreement" +} diff --git a/app/src/main/java/com/yzx/kiosk/network/datasource/LocalDataSource.kt b/app/src/main/java/com/yzx/kiosk/network/datasource/LocalDataSource.kt new file mode 100644 index 0000000..3b1e696 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/datasource/LocalDataSource.kt @@ -0,0 +1,56 @@ +package com.yzx.kiosk.network.datasource + +import com.yzx.kiosk.network.db.UploadTaskDao +import com.yzx.kiosk.network.enums.UploadStatus +import com.yzx.kiosk.network.model.entity.UploadTaskEntity +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalDataSource @Inject constructor( + private val uploadTaskDao: UploadTaskDao +) { + private val maxFileSize = 2048L * 1024 * 1024 // 2048MB + + fun observeAllUploadTasks(): Flow> { + return uploadTaskDao.observeAllTasks() + } + + suspend fun getPendingUploadTasks(): List { + return uploadTaskDao.getTasksByStatuses(listOf(UploadStatus.PENDING, UploadStatus.PAUSED)) + } + + suspend fun getUploadTaskById(taskId: String): UploadTaskEntity? { + return uploadTaskDao.getTaskById(taskId) + } + + suspend fun insertOrUpdateUploadTasks(tasks: List) { + uploadTaskDao.insertTasks(tasks) + } + + suspend fun updateUploadTaskStatus(task: UploadTaskEntity) { + uploadTaskDao.updateTask(task) + } + + suspend fun updateTaskStatusOnly(taskId: String, status: UploadStatus) { + uploadTaskDao.updateTaskStatus(taskId, status) + } + + suspend fun updateUploadTaskProgress( + taskId: String, + progress: Int, + uploadedSize: Long + ) { + uploadTaskDao.updateTaskProgress(taskId, progress, uploadedSize) + } + + suspend fun deleteUploadTask(taskId: String) { + uploadTaskDao.deleteTask(taskId) + } + + fun isFileSizeExceedLimit(fileSize: Long): Boolean { + return fileSize > maxFileSize + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/network/db/AppDatabase.kt b/app/src/main/java/com/yzx/kiosk/network/db/AppDatabase.kt new file mode 100644 index 0000000..f5938b8 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/db/AppDatabase.kt @@ -0,0 +1,14 @@ +package com.yzx.kiosk.network.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.yzx.kiosk.network.model.entity.UploadTaskEntity + +@Database( + entities = [UploadTaskEntity::class], + version = 8, // 从之前的版本(如1)增加到2 + exportSchema = true // 开启便于备份schema +) +abstract class AppDatabase : RoomDatabase() { + abstract fun uploadTaskDao(): UploadTaskDao +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/db/DatabaseModule.kt b/app/src/main/java/com/yzx/kiosk/network/db/DatabaseModule.kt new file mode 100644 index 0000000..2fbb11c --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/db/DatabaseModule.kt @@ -0,0 +1,42 @@ +package com.yzx.kiosk.network.db + +import android.content.Context +import androidx.room.Room +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "app_database" + ) + .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_7_8) + .build() + } + + @Provides + fun provideUploadTaskDao(database: AppDatabase): UploadTaskDao { + return database.uploadTaskDao() + } + + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + // 删除旧的唯一索引 + db.execSQL("DROP INDEX IF EXISTS index_upload_tasks_filePath_folderId") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/db/UploadTaskDao.kt b/app/src/main/java/com/yzx/kiosk/network/db/UploadTaskDao.kt new file mode 100644 index 0000000..fed54e7 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/db/UploadTaskDao.kt @@ -0,0 +1,68 @@ +package com.yzx.kiosk.network.db + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Update +import androidx.room.Upsert +import com.yzx.kiosk.network.enums.UploadStatus +import com.yzx.kiosk.network.model.entity.UploadTaskEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface UploadTaskDao { + + @Query("SELECT id FROM upload_tasks WHERE id = :taskId") + suspend fun getTaskIdById(taskId: String): String? + + + @Query("SELECT * FROM upload_tasks ORDER BY updatedAt DESC") + fun observeAllTasks(): Flow> + + @Query("SELECT * FROM upload_tasks WHERE status != :excludeStatus ORDER BY updatedAt DESC") + fun observeActiveTasks(excludeStatus: UploadStatus = UploadStatus.COMPLETED): Flow> + + @Query("SELECT * FROM upload_tasks WHERE id = :taskId") + suspend fun getTaskById(taskId: String): UploadTaskEntity? + + @Upsert + suspend fun insertTask(task: UploadTaskEntity) + + @Upsert + suspend fun insertTasks(tasks: List) + + @Update + suspend fun updateTask(task: UploadTaskEntity) + + @Query("UPDATE upload_tasks SET status = :status, updatedAt = :updatedAt WHERE id = :taskId") + suspend fun updateTaskStatus( + taskId: String, + status: UploadStatus, + updatedAt: Long = System.currentTimeMillis() + ) + + @Query("UPDATE upload_tasks SET progress = :progress, uploadedSize = :uploadedSize, updatedAt = :updatedAt WHERE id = :taskId") + suspend fun updateTaskProgress( + taskId: String, + progress: Int, + uploadedSize: Long, + updatedAt: Long = System.currentTimeMillis() + ) + + @Query("DELETE FROM upload_tasks WHERE id = :taskId") + suspend fun deleteTask(taskId: String) + + @Query("SELECT * FROM upload_tasks WHERE status IN (:statuses)") + suspend fun getTasksByStatuses(statuses: List): List + + @Query("UPDATE upload_tasks SET status = :newStatus WHERE status = :oldStatus") + suspend fun updateTasksStatus(oldStatus: UploadStatus, newStatus: UploadStatus) + + @Query("DELETE FROM upload_tasks WHERE status = :status") + suspend fun deleteTasksByStatus(status: UploadStatus) + + @Query("UPDATE upload_tasks SET status = :newStatus WHERE status = :oldStatus") + fun updateTasksStatusByStatus(oldStatus: UploadStatus, newStatus: UploadStatus) + + @Query("UPDATE upload_tasks SET status = :status WHERE id = :taskId") + suspend fun updateTaskStatusOnly(taskId: String, status: UploadStatus) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/di/CoroutineModule.kt b/app/src/main/java/com/yzx/kiosk/network/di/CoroutineModule.kt new file mode 100644 index 0000000..52de0d0 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/di/CoroutineModule.kt @@ -0,0 +1,26 @@ +package com.yzx.kiosk.network.di + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import dagger.Provides +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineModule { + /** + * 提供一个应用级的 CoroutineScope + * 使用 SupervisorJob 确保一个子协程失败不会影响其他子协程 + * 使用 Dispatchers.Main 作为默认调度器(可根据需要修改) + */ + @Provides + @Singleton + fun provideApplicationCoroutineScope(): CoroutineScope { + return CoroutineScope(SupervisorJob() + Dispatchers.Main) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt b/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt new file mode 100644 index 0000000..f164ee2 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt @@ -0,0 +1,130 @@ +package com.yzx.kiosk.network.di + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.network.interceptor.RequestInterceptor +import com.yzx.kiosk.network.interceptor.ResponseInterceptor +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + private const val TIMEOUT_SHORT = 30L + private const val TIMEOUT_LONG = 60L + + private const val BASE_URL = "baseUrl" + private const val CLIENT_DEFAULT = "defaultOkHttpClient" + const val CLIENT_UPLOAD = "uploadOkHttpClient" + const val CLIENT_DOWNLOAD = "downloadOkHttpClient" + const val RETROFIT_DEFAULT = "defaultRetrofit" + const val RETROFIT_UPLOAD = "uploadRetrofit" + const val TRANSFRTVIEWMODEL = "transferViewModel" + + @Provides + @Singleton + @Named(BASE_URL) + fun provideBaseUrl(): String = BuildConfig.BASE_URL + + @Provides + @Singleton + fun provideGson(): Gson = GsonBuilder().create() + + @Provides + @Singleton + fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply { + level = + if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE + } + + private fun buildOkHttpClient( + timeout: Long, + requestInterceptor: RequestInterceptor, + responseInterceptor: ResponseInterceptor, + loggingInterceptor: HttpLoggingInterceptor? + ): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(timeout, TimeUnit.SECONDS) + .writeTimeout(timeout, TimeUnit.SECONDS) + .readTimeout(timeout, TimeUnit.SECONDS) + .callTimeout(timeout, TimeUnit.SECONDS) + .addInterceptor(requestInterceptor) + .addInterceptor(responseInterceptor) + .apply { + if (BuildConfig.DEBUG) loggingInterceptor?.let { addInterceptor(it) } + }.build() + + @Provides + @Singleton + @Named(CLIENT_DEFAULT) + fun provideDefaultOkHttpClient( + requestInterceptor: RequestInterceptor, + responseInterceptor: ResponseInterceptor, + loggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient = buildOkHttpClient( + TIMEOUT_SHORT, + requestInterceptor, + responseInterceptor, + loggingInterceptor, + ) + + @Provides + @Singleton + @Named(CLIENT_UPLOAD) + fun provideUploadOkHttpClient( + requestInterceptor: RequestInterceptor, + responseInterceptor: ResponseInterceptor, + ): OkHttpClient = buildOkHttpClient(TIMEOUT_LONG, requestInterceptor, responseInterceptor, null) + + @Provides + @Singleton + @Named(CLIENT_DOWNLOAD) + fun provideDownloadOkHttpClient( + requestInterceptor: RequestInterceptor, + ): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(TIMEOUT_LONG, TimeUnit.SECONDS) + .writeTimeout(TIMEOUT_LONG, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_LONG, TimeUnit.SECONDS) + .callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS) + .addInterceptor(requestInterceptor) + // 注意:不添加 ResponseInterceptor,避免将大文件加载到内存 + .build() + + private fun buildRetrofit( + baseUrl: String, + okHttpClient: OkHttpClient, + gson: Gson + ): Retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + @Provides + @Singleton + @Named(RETROFIT_DEFAULT) + fun provideDefaultRetrofit( + @Named(CLIENT_DEFAULT) okHttpClient: OkHttpClient, + gson: Gson, + @Named(BASE_URL) baseUrl: String + ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) + + @Provides + @Singleton + @Named(RETROFIT_UPLOAD) + fun provideUploadRetrofit( + @Named(CLIENT_UPLOAD) okHttpClient: OkHttpClient, + gson: Gson, + @Named(BASE_URL) baseUrl: String + ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/di/ServiceModule.kt b/app/src/main/java/com/yzx/kiosk/network/di/ServiceModule.kt new file mode 100644 index 0000000..82a4f08 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/di/ServiceModule.kt @@ -0,0 +1,45 @@ +package com.yzx.kiosk.network.di + +import android.content.Context +import com.yzx.kiosk.audio.AudioPlayService +import com.yzx.kiosk.network.service.NetworkService +import com.yzx.kiosk.network.service.UploadService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT +import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD +import retrofit2.Retrofit +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ServiceModule { + + @Provides + @Singleton + fun provideNetworkService( + @Named(RETROFIT_DEFAULT) retrofit: Retrofit + ): NetworkService = retrofit.create(NetworkService::class.java) + + @Provides + @Singleton + fun provideUploadService( + @Named(RETROFIT_UPLOAD) retrofit: Retrofit + ): UploadService = retrofit.create(UploadService::class.java) + + @Provides + @Singleton + fun provideAudioPlayService( + @ApplicationContext context: Context + ): AudioPlayService = AudioPlayService(context) + + @Provides + @Singleton + fun provideLocalAudioPlayService( + @ApplicationContext context: Context + ): com.yzx.kiosk.audio.LocalAudioPlayService = com.yzx.kiosk.audio.LocalAudioPlayService(context) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/di/UploadModule.kt b/app/src/main/java/com/yzx/kiosk/network/di/UploadModule.kt new file mode 100644 index 0000000..d195855 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/di/UploadModule.kt @@ -0,0 +1,51 @@ +package com.yzx.kiosk.network.di + +import android.content.Context +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.manager.UploadManager +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.repository.TransferRepository +import com.yzx.kiosk.network.datasource.LocalDataSource +import com.yzx.kiosk.utils.AliyunOSSUtil +import dagger.hilt.InstallIn +import dagger.Module +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import dagger.Provides +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class UploadModule { + +// @Provides +// @Singleton +// fun provideApplicationScope(@ApplicationContext context: Context): CoroutineScope { +// return CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) +// } + + @Provides + @Singleton + fun provideUploadManager( + localDataSource: LocalDataSource, + networkRepository: NetWorkRepository, + appStore: AppStoreDataSource, + @ApplicationContext context: Context, + aliyunOSSUtil: AliyunOSSUtil, + transferRepository: TransferRepository, + applicationScope: CoroutineScope + + ): UploadManager { + return UploadManager( + localDataSource, + networkRepository, + context, + aliyunOSSUtil, + transferRepository, + applicationScope + ) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/dialog/GlobalDialog.kt b/app/src/main/java/com/yzx/kiosk/network/dialog/GlobalDialog.kt new file mode 100644 index 0000000..78d6e52 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/dialog/GlobalDialog.kt @@ -0,0 +1,47 @@ +package com.yzx.kiosk.network.dialog + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import com.yzx.kiosk.network.result.GlobalMessageHandler + +// 单点登录对话框 +@Composable +fun SSODialog( + message: GlobalMessageHandler.PushMessage, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = message.title, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton( + onClick = { + onConfirm() + onDismiss() + } + ) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/enums/UploadStatus.kt b/app/src/main/java/com/yzx/kiosk/network/enums/UploadStatus.kt new file mode 100644 index 0000000..e2097b9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/enums/UploadStatus.kt @@ -0,0 +1,28 @@ +package com.yzx.kiosk.network.enums + +enum class UploadStatus { + /** + * 等待上传 + */ + PENDING, // 等待上传 + + /** + * 正在上传 + */ + UPLOADING, // 正在上传 + + /** + * 已暂停 + */ + PAUSED, // 已暂停 + + /** + * 已完成 + */ + COMPLETED, // 已完成 + + /** + * 上传失败 + */ + FAILED // 上传失败 +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/interceptor/RequestInterceptor.kt b/app/src/main/java/com/yzx/kiosk/network/interceptor/RequestInterceptor.kt new file mode 100644 index 0000000..ee3b222 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/interceptor/RequestInterceptor.kt @@ -0,0 +1,51 @@ +package com.yzx.kiosk.network.interceptor + +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.utils.Md5Utils +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RequestInterceptor @Inject constructor( + private val appStore: AppStoreDataSource, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // 获取设备SN与设备密钥(无密钥时不生成 token) + val deviceSN = appStore.getDeviceSN() + val appSecret = appStore.getDeviceSecret() + + // 生成时间戳(秒级) + val timestamp = (System.currentTimeMillis() / 1000).toString() + + // 生成token: md5(SN + App密钥 + timestamp),仅当 SN 与密钥均存在时生成 + val token = if (deviceSN.isNotEmpty() && appSecret.isNotEmpty()) { + val tokenString = deviceSN + appSecret + timestamp + Md5Utils.md5(tokenString) + } else { + "" + } + + val requestBuilder = originalRequest.newBuilder() + .addHeader("Accept", "application/json") + + // 添加SN和Token到HEADER + if (deviceSN.isNotEmpty()) { + requestBuilder.addHeader("sn", deviceSN) + } + if (token.isNotEmpty()) { + requestBuilder.addHeader("token", token) + } + + // 添加时间戳(可选,如果需要的话) + requestBuilder.addHeader("timestamp", timestamp) + + val newRequest = requestBuilder.build() + + return chain.proceed(newRequest) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/interceptor/ResponseInterceptor.kt b/app/src/main/java/com/yzx/kiosk/network/interceptor/ResponseInterceptor.kt new file mode 100644 index 0000000..f89ed5a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/interceptor/ResponseInterceptor.kt @@ -0,0 +1,47 @@ +package com.yzx.kiosk.network.interceptor + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ResponseInterceptor @Inject constructor( +) : Interceptor { + private val gson: Gson = Gson() + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + + val responseBody = response.body + val contentType = responseBody?.contentType() + + if (!response.isSuccessful || responseBody == null) { + return response + } + + val responseStr = responseBody.string() + try { + val root = JsonParser.parseString(responseStr).asJsonObject + val code = root["code"]?.asInt + val fixedJson = if (code != 100000) { + // 把 data 统一写成 {} + root.add("data", JsonObject()) + gson.toJson(root) + } else { + // 成功时还原原始响应字符串 + responseStr + } + val newBody = fixedJson.toResponseBody(contentType) + return response.newBuilder().body(newBody).build() + } catch (_: Exception) { + // 不是 json,直接返回原始字符串包装的新 response + val newBody = responseStr.toResponseBody(contentType) + return response.newBuilder().body(newBody).build() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/manager/UploadManager.kt b/app/src/main/java/com/yzx/kiosk/network/manager/UploadManager.kt new file mode 100644 index 0000000..24fa3af --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/manager/UploadManager.kt @@ -0,0 +1,590 @@ +package com.yzx.kiosk.network.manager + +import android.content.Context +import com.yzx.kiosk.network.enums.UploadStatus +import com.yzx.kiosk.network.model.entity.UploadTaskEntity +import com.yzx.kiosk.network.model.response.NetworkResponse +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.repository.TransferRepository +import com.yzx.kiosk.network.result.Result +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.network.datasource.LocalDataSource +import com.yzx.kiosk.network.model.response.AliyunOSSResponse +import com.yzx.kiosk.utils.AliyunOSSUtil +import com.yzx.kiosk.utils.LogUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UploadManager @Inject constructor( + + /** + * 本地数据源,用于操作数据库中的上传任务。 + */ + private val localDataSource: LocalDataSource, + + /** + * 网络仓库,用于上传文件。 + */ + private val networkRepository: NetWorkRepository, + + + @ApplicationContext private val context: Context, + /** + * 阿里云OSS工具类,用于上传文件。 + */ + private val aliyunOSSUtil: AliyunOSSUtil, + + /** + * 传输仓库,用于管理上传任务。 + */ + private val transferRepository: TransferRepository, + + /** + * 协程作用域,用于在后台执行上传任务。 + */ + private val applicationScope: CoroutineScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) +) { + /** + * 是否正在上传中。 + */ + private var isUploading = false + + /** + * 上传任务队列 + */ + private val uploadQueue = ArrayDeque() + + /** + * 当前正在执行的上传任务Job。 + */ + private var currentJob: Job? = null + + + /** + * 正在上传的任务列表(状态流) + */ + private val _uploadTasks = MutableStateFlow>(emptyList()) + val uploadTasks: StateFlow> = _uploadTasks + + /** + * 已完成的任务列表(状态流) + */ + private val _completedTasks = MutableStateFlow>(emptyList()) + val completedTasks: StateFlow> = _completedTasks + + /** + * 上传成功事件流(用于通知上传成功,特别是音频和海报上传) + */ + private val _uploadSuccessEvent = MutableSharedFlow(extraBufferCapacity = 64) + val uploadSuccessEvent: SharedFlow = _uploadSuccessEvent.asSharedFlow() + + + // 跟踪当前任务 + private var currentUploadingTask: UploadTaskEntity? = null + + /** + * 初始化时注册多个监听器,用于响应任务添加、启动、取消、重试等事件 + */ + init { + applicationScope.launch { + transferRepository.tasksAddedEvent.collect { tasks -> + addTasksToQueue(tasks) // ✅ 统一由 UploadManager 做入库+入队 + } + } + + // 监听启动上传指令 + applicationScope.launch { + transferRepository.startUploadCommands.collect { taskId -> + resumeUpload(taskId) + } + } + + // 监听取消上传指令 + applicationScope.launch { + transferRepository.cancelUploadCommands.collect { taskId -> + cancelUpload(taskId) + } + } + + // 监听重试上传指令 + applicationScope.launch { + transferRepository.retryUploadCommands.collect { taskId -> + resumeTask(taskId) + } + } + + // 观察任务变化,更新状态 + applicationScope.launch { + localDataSource.observeAllUploadTasks().collect { tasks -> + _uploadTasks.value = tasks.filter { it.status != UploadStatus.COMPLETED } + _completedTasks.value = tasks.filter { it.status == UploadStatus.COMPLETED } + } + } + + // 启动时加载任务 + applicationScope.launch { + loadTasks() + } + + // 添加定期检查任务状态的机制20250829 + applicationScope.launch { + while (true) { + delay(5000) // 每5秒检查一次 + if (!isUploading && uploadQueue.isEmpty()) { + loadTasks() // 重新加载任务 + } + } + } + + + // 启动定期清理任务 + scheduleCheckpointCleanup() + + // 应用启动时立即清理一次 + applicationScope.launch(Dispatchers.IO) { + aliyunOSSUtil.cleanExpiredCheckpointFiles(context) + } + } + + /** + * 启动时从本地加载所有未完成的任务,加入队列并开始上传。 + */ + private suspend fun loadTasks() { + val allTasks = localDataSource.observeAllUploadTasks().first() + _uploadTasks.value = allTasks.filter { it.status != UploadStatus.COMPLETED } + _completedTasks.value = allTasks.filter { it.status == UploadStatus.COMPLETED } + + val pendingTasks = allTasks.filter { task -> + task.status == UploadStatus.PENDING || task.status == UploadStatus.PAUSED + } + // 添加去重检查:只添加不在队列中的任务 + val newTasks = pendingTasks.filter { task -> + !uploadQueue.any { it.id == task.id } + } + + if (newTasks.isNotEmpty()) { + uploadQueue.addAll(newTasks) + if (!isUploading) { + startUploading() + } + } + } + + fun startUploading() { + if (isUploading) return + applicationScope.launch { + isUploading = true + var pendingTasks = localDataSource.getPendingUploadTasks() + LogUtils.d("pendingTasks.size = ${pendingTasks.size}") + + // 更新所有非PENDING状态的任务为PENDING + val tasksToUpdate = pendingTasks.filter { it.status != UploadStatus.PENDING } + if (tasksToUpdate.isNotEmpty()) { + localDataSource.insertOrUpdateUploadTasks( + tasksToUpdate.map { it.copy(status = UploadStatus.PENDING) } + ) + // 重新获取任务列表以获取更新后的状态 + pendingTasks = localDataSource.getPendingUploadTasks() + } + + if (pendingTasks.isNotEmpty()) { + uploadQueue.addAll(pendingTasks) + LogUtils.d("pendingTasks.isNotEmpty().pendingTasks.size = ${pendingTasks.size}") + processQueue() + } else { + isUploading = false + } + } + } + + /** + * 暂停单个上传任务,取消当前Job和OSS上传。 + */ + suspend fun pauseTask(taskId: String) { + val task = localDataSource.getUploadTaskById(taskId) + if (task != null && task.status == UploadStatus.UPLOADING) { + // 先打断队列驱动 + isUploading = false // <—— 新增:立刻“拉手刹” + // 标记暂停(仅改状态,进度保持,见问题 2 的 DAO 改法) + localDataSource.updateTaskStatusOnly(taskId, UploadStatus.PAUSED) + // 从队列移除该任务(容错见问题 3) + uploadQueue.removeAll { it.id == taskId } + // 取消正在跑的 Job 和 SDK 任务 + currentJob?.cancelAndJoin() + currentJob = null + aliyunOSSUtil.cancelUpload() + } + } + + + /** + * 恢复单个上传任务,将其加入队列并开始处理。 + */ + suspend fun resumeTask(taskId: String) { + val task = localDataSource.getUploadTaskById(taskId) + if (task != null && (task.status == UploadStatus.PAUSED || task.status == UploadStatus.FAILED || task.status == UploadStatus.PENDING)) { + // 检查任务是否已在队列中 + if (uploadQueue.any { it.id == taskId }) { + LogUtils.d("Task $taskId is already in queue") + return + } + // 更新状态为待上传 + localDataSource.updateUploadTaskStatus(task.copy(status = UploadStatus.PENDING)) + // 添加去重检查:确保任务不在队列中才添加 + if (!uploadQueue.any { it.id == taskId }) { + uploadQueue.addFirst(task) + } + + if (!isUploading) { + startUploading() + } + } + } + + /** + * 从数据库和队列中移除任务。 + */ + suspend fun removeTask(taskId: String) { + val task = localDataSource.getUploadTaskById(taskId) + + if (task?.status == UploadStatus.UPLOADING) { + // 取消当前上传 Job 与 SDK 上传线程 + currentJob?.cancelAndJoin() + currentJob = null + aliyunOSSUtil.cancelUpload() + isUploading = false + } + + // 删除数据库与队列 + localDataSource.deleteUploadTask(taskId) + uploadQueue.removeIf { it.id == taskId } + + // 清理该任务对应的断点文件 + withContext(Dispatchers.IO) { + aliyunOSSUtil.cleanCheckpointFileForTask(context, taskId) + } + } + + // processQueue 方法 + private suspend fun processQueue() { + while (uploadQueue.isNotEmpty() && isUploading) { + val task = uploadQueue.first() + + val latest = localDataSource.getUploadTaskById(task.id) + if (latest == null || latest.status != UploadStatus.PENDING) { + // 仅当队首仍是本任务时再 removeFirst,否则按 ID 清理 + if (uploadQueue.isNotEmpty() && uploadQueue.first().id == task.id) { + uploadQueue.removeFirst() + } else { + uploadQueue.removeAll { it.id == task.id } + } + continue + } + + localDataSource.updateTaskStatusOnly(task.id, UploadStatus.UPLOADING) + + try { + val ok = uploadTask(task) + // 只移除“仍在队首”的同一任务;否则按 ID 清理,避免并发移除导致崩溃 + if (uploadQueue.isNotEmpty() && uploadQueue.first().id == task.id) { + uploadQueue.removeFirst() + } else { + uploadQueue.removeAll { it.id == task.id } + } + if (!ok) break + } catch (e: Exception) { + // 同样用“按 ID 移除”兜底 + uploadQueue.removeAll { it.id == task.id } + LogUtils.e("UploadManager", "任务上传异常: ${task.id}, ${e.message}") + break + } + } + isUploading = false + currentUploadingTask = null + } + + // 上传任务方法 + private suspend fun uploadTask( + task: UploadTaskEntity, + ): Boolean { + return withContext(Dispatchers.IO) { + try { + // 获取OSS配置 + val ossConfig = getOssConfig() + if (ossConfig == null) { + handleUploadFailure(task) + return@withContext false + } + + // 初始化OSS客户端 + aliyunOSSUtil.initOSSClient(context, ossConfig) + // 使用 CompletableDeferred 来等待上传结果 + val deferred = CompletableDeferred() + + aliyunOSSUtil.uploadFile( + task = task, + onProgress = { progress, uploadedSize -> + applicationScope.launch { + localDataSource.updateUploadTaskProgress( + task.id, + progress, + uploadedSize + ) + } + }, + onSuccess = { fileUrl -> + applicationScope.launch { + handleUploadSuccess(task, fileUrl) + deferred.complete(true) // ✅ 成功完成 + } + }, + onFailure = { errorMessage -> + applicationScope.launch { + if (errorMessage == "__CANCELLED__") { + localDataSource.updateTaskStatusOnly(task.id, UploadStatus.PAUSED) + } else { + handleUploadFailure(task) + } + deferred.complete(false) // ✅ 失败也要完成,别让 await 卡住 + } + }, + context = context + ) + + return@withContext deferred.await() + + } catch (e: Exception) { + LogUtils.e("UploadManager", "上传任务异常: ${e.message}") + false + } + } + } + + suspend fun uploadOnlyTask( + task: UploadTaskEntity, + onProgress: (Int, Long) -> Unit, + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + ): Boolean { + return withContext(Dispatchers.IO) { + try { + // 获取OSS配置 + val ossConfig = getOssConfig() + if (ossConfig == null) { + handleUploadFailure(task) + return@withContext false + } + + // 初始化OSS客户端 + aliyunOSSUtil.initOSSClient(context, ossConfig) + // 使用 CompletableDeferred 来等待上传结果 + val deferred = CompletableDeferred() + + aliyunOSSUtil.uploadFile( + task = task, + onProgress = onProgress, + onSuccess = { url -> + onSuccess(url) + deferred.complete(true) + }, + onFailure = { error -> + onFailure(error) + if (!deferred.isCompleted) { + deferred.complete(false) + } + }, + context = context + ) + + return@withContext deferred.await() + + } catch (e: Exception) { + LogUtils.e("UploadManager", "上传任务异常: ${e.message}") + false + } + } + } + + // 新增获取OSS配置方法 + private suspend fun getOssConfig(): AliyunOSSResponse? { + return try { + val result = networkRepository.aliyunOSSBucket("facebox").asResult() + var finalResult: Result>? = null + + result.collect { res -> + if (res is Result.Success || res is Result.Error) { + finalResult = res + } + } + + when (val fResult = finalResult) { + is Result.Success -> { + val networkResponse = fResult.data as? NetworkResponse + if (networkResponse != null && networkResponse.isSucceeded) { + networkResponse.data + } else { + null + } + } + + else -> null + } + } catch (e: Exception) { + LogUtils.e("UploadManager", "获取OSS配置异常: ${e.message}") + null + } + } + + // 修改 handleUploadSuccess 和 handleUploadFailure 方法,不再调用 processQueue + private suspend fun handleUploadSuccess(task: UploadTaskEntity, fileUrl: String) { + val completedTask = task.copy( + status = UploadStatus.COMPLETED, + progress = 100, + fileUrl = fileUrl, + updatedAt = System.currentTimeMillis() + ) + localDataSource.updateUploadTaskStatus(completedTask) + + // 从队列中移除已完成的任务 + uploadQueue.removeAll { it.id == task.id } + + // 更新状态流 + val currentUploadTasks = _uploadTasks.value.filter { it.id != task.id } + val currentCompletedTasks = _completedTasks.value + completedTask + _uploadTasks.value = currentUploadTasks + _completedTasks.value = currentCompletedTasks + + withContext(Dispatchers.IO) { + networkRepository.pushUrlByAliyun( + scenicid = task.scenicId, + url = fileUrl, + folderId = task.folderId + ).asResult().collect { result -> + when (result) { + is Result.Success -> { + LogUtils.i("文件URL上传到服务器成功: $fileUrl") + // 发送上传成功事件(用于设备配置页面直接更新列表) + _uploadSuccessEvent.tryEmit(completedTask) + applicationScope.launch { + transferRepository.sendUploadCompleted(task.folderId.toString()) + } + } + + is Result.Error -> { + LogUtils.e("文件URL上传到服务器失败: ${result.exception.message}") + } + + else -> {} + } + } + aliyunOSSUtil.cleanCheckpointFileForTask(context, task.id) + } + + // 不再调用 processQueue() + } + + private suspend fun handleUploadFailure(task: UploadTaskEntity) { + val failedTask = task.copy( + status = UploadStatus.FAILED, + updatedAt = System.currentTimeMillis() + ) + localDataSource.updateUploadTaskStatus(failedTask) + + // 不再调用 processQueue() + } + + /** + * + */ + fun pauseUpload(taskId: String) { + applicationScope.launch { + pauseTask(taskId) + } + } + + + fun resumeUpload(taskId: String) { + applicationScope.launch { + resumeTask(taskId) + } + } + + fun cancelUpload(taskId: String) { + applicationScope.launch { + removeTask(taskId) + } + } + + + // 添加一个定期清理任务的方法 + private fun scheduleCheckpointCleanup() { + applicationScope.launch { + // 每24小时清理一次过期断点文件 + while (true) { + delay(24 * 60 * 60 * 1000L) // 24小时 + withContext(Dispatchers.IO) { + aliyunOSSUtil.cleanExpiredCheckpointFiles(context) + } + } + } + } + + // UploadManager.kt + /** + * 将多个任务添加到队列,并检查文件大小限制。 + * 只按 task.id 判重,允许同文件路径再次上传(只要换了 id)。 + */ + suspend fun addTasksToQueue(tasks: List) { + if (tasks.isEmpty()) return + + val toInsert = mutableListOf() + val toEnqueue = mutableListOf() + + for (task in tasks) { + // 1) 大小校验(超限跳过) + if (localDataSource.isFileSizeExceedLimit(task.fileSize)) continue + + // 2) 只按 id 判重;允许同 filePath/folderId 再次上传 + val existsInDb = localDataSource.getUploadTaskById(task.id) != null + if (!existsInDb) { + toInsert += task.copy(status = UploadStatus.PENDING) + } + + if (!uploadQueue.any { it.id == task.id }) { + toEnqueue += task.copy(status = UploadStatus.PENDING) + } + } + + // 3) 一次性入库“新增任务” + if (toInsert.isNotEmpty()) { + localDataSource.insertOrUpdateUploadTasks(toInsert) + } + + // 4) 入队 + if (toEnqueue.isNotEmpty()) { + uploadQueue.addAll(toEnqueue) + } + + // 5) 如未在上传则启动 + if (!isUploading) { + startUploading() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/model/entity/CredentialsEntity.kt b/app/src/main/java/com/yzx/kiosk/network/model/entity/CredentialsEntity.kt new file mode 100644 index 0000000..e0e538b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/entity/CredentialsEntity.kt @@ -0,0 +1,31 @@ +package com.yzx.kiosk.network.model.entity + +import com.google.gson.annotations.SerializedName + +data class CredentialsEntity ( + + /** + *临时访问凭证的Access Key ID,用于身份验证 + */ + @SerializedName("access_key_id") + val access_key_id: String, + + /** + *临时访问凭证的Access Key Secret,用于身份验证。 + */ + @SerializedName("access_key_secret") + val access_key_secret: String, + + /** + *临时访问凭证的安全令牌,用于身份验证。 + */ + @SerializedName("security_token") + val security_token: String, + + /** + *具体的时间,也可以使用 expire_seconds 字段设置信息有效期 + */ + @SerializedName("expiration") + val expiration: String, + +) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/model/entity/DownloadTaskEntity.kt b/app/src/main/java/com/yzx/kiosk/network/model/entity/DownloadTaskEntity.kt new file mode 100644 index 0000000..84d991c --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/entity/DownloadTaskEntity.kt @@ -0,0 +1,59 @@ +package com.yzx.kiosk.network.model.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.google.gson.annotations.SerializedName + +// 下载状态 +enum class DownloadStatus { + PENDING, // 待开始 + DOWNLOADING, // 下载中 + PAUSED, // 已暂停 + FAILED, // 失败 + COMPLETED // 完成 +} + +@Entity(tableName = "DownloadTask") +data class DownloadTaskEntity( + @SerializedName("id") + @PrimaryKey val id: String, + + /** OSS 的完整 URL,例如 https://xxx.aliyuncs.com/cloud_driver/xxx.mp4 */ + @SerializedName("fileUrl") + val fileUrl: String, + + /** 显示用的文件名 */ + @SerializedName("fileName") + val fileName: String, + + /** 1视频 / 2图片,仅用来显示 icon */ + @SerializedName("fileType") + val fileType: Int, + + /** 本地保存路径 */ + @SerializedName("targetPath") + val targetPath: String, + + /** 状态 */ + @SerializedName("status") + val status: DownloadStatus = DownloadStatus.PENDING, + + /** 进度(0-100) */ + @SerializedName("progress") + val progress: Int = 0, + + /** 已下载字节数 */ + @SerializedName("downloadedSize") + val downloadedSize: Long = 0L, + + /** 总大小(可选,拿不到就 0) */ + @SerializedName("totalSize") + val totalSize: Long = 0L, + + /** 创建/更新时戳 */ + @SerializedName("createdAt") + val createdAt: Long = System.currentTimeMillis(), + + @SerializedName("updatedAt") + val updatedAt: Long = System.currentTimeMillis(), +) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/model/entity/MsgEntity.kt b/app/src/main/java/com/yzx/kiosk/network/model/entity/MsgEntity.kt new file mode 100644 index 0000000..eab4d37 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/entity/MsgEntity.kt @@ -0,0 +1,53 @@ +package com.yzx.kiosk.network.model.entity + +import com.google.gson.annotations.SerializedName + +data class MsgEntity( + @SerializedName("id") + val id: Int = -1, + + @SerializedName("receiver_id") + val receiver_id: Int = -1, + + @SerializedName("receiver_type") + val receiver_type: String = "", + + @SerializedName("type") + val type: Int = -1, + + @SerializedName("type_name") + val type_name: String = "", + + @SerializedName("title") + val title: String = "", + + @SerializedName("content") + val content: String = "", + + @SerializedName("push_at") + var push_at: String = "", + + @SerializedName("is_read") + val is_read: Boolean, + + @SerializedName("read_at") + val read_at: String = "", + + @SerializedName("push_channel") + val push_channel: Int = -1, + + @SerializedName("push_channel_name") + val push_channel_name: String = "", + + @SerializedName("extra_data") + val extra_data: Map = emptyMap(), + + @SerializedName("created_at") + var created_at: String = "", + + @SerializedName("updated_at") + var updated_at: String = "" + + +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/entity/OssFileTaskEntity.kt b/app/src/main/java/com/yzx/kiosk/network/model/entity/OssFileTaskEntity.kt new file mode 100644 index 0000000..94ae5b7 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/entity/OssFileTaskEntity.kt @@ -0,0 +1,62 @@ +package com.yzx.kiosk.network.model.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.google.gson.annotations.SerializedName +import com.yzx.kiosk.network.enums.UploadStatus + +@Entity(tableName = "OssFileTask") +data class OssFileTaskEntity( + @SerializedName("id") + @PrimaryKey val id: String, + + @SerializedName("fileName") + var fileName: String, + + /** 1视频 / 2图片 */ + @SerializedName("fileType") + var fileType: Int, + + /** 文件大小(字节) */ + @SerializedName("fileSize") + var fileSize: Long, + + /** 本地文件路径(可为 file:// 或 content://,CloudOssUtils 已兼容) */ + @SerializedName("filePath") + var filePath: String, + + /** 父级目录ID */ + @SerializedName("parentFolderId") + var parentFolderId: Int, + + /** 上传后的URL */ + @SerializedName("fileUrl") + var fileUrl: String? = null, + + /** 上传状态 */ + @SerializedName("status") + var status: UploadStatus = UploadStatus.PENDING, + + /** 进度(0-100) */ + @SerializedName("progress") + var progress: Int = 0, + + /** 已上传字节数(断点续传用) */ + @SerializedName("uploadedSize") + var uploadedSize: Long = 0, + + /** 创建/更新时戳 */ + @SerializedName("createdAt") + var createdAt: Long = System.currentTimeMillis(), + + @SerializedName("updatedAt") + var updatedAt: Long = System.currentTimeMillis(), + + /** 封面(可选) */ + @SerializedName("coverUrl") + var coverUrl: String? = null, + + // ✅ 新增字段:上传时的稳定 key(决定断点续传命中) + @SerializedName("objectKey") + val objectKey: String = "", +) diff --git a/app/src/main/java/com/yzx/kiosk/network/model/entity/UploadTaskEntity.kt b/app/src/main/java/com/yzx/kiosk/network/model/entity/UploadTaskEntity.kt new file mode 100644 index 0000000..9c16590 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/entity/UploadTaskEntity.kt @@ -0,0 +1,117 @@ +package com.yzx.kiosk.network.model.entity + + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.google.gson.annotations.SerializedName +import com.yzx.kiosk.network.enums.UploadStatus + +@Entity(tableName = "upload_tasks") +data class UploadTaskEntity ( + + /** + * id + */ + @SerializedName("id") + @PrimaryKey val id: String, // 唯一标识,使用UUID + + /** + * 文件名 + */ + @SerializedName("fileName") + var fileName: String, // 文件名 + + /** + * 文件类型 1视频/2图片 + */ + @SerializedName("fileType") + var fileType: Int, // 文件类型 1视频/2图片3.音频 + + /** + * 文件大小(字节) + */ + @SerializedName("fileSize") + var fileSize: Long, // 文件大小(字节) + + /** + * 本地文件路径同localPath + */ + @SerializedName("filePath") + var filePath: String, // 本地文件路径 + + /** + * 景区ID + */ + @SerializedName("scenicId") + var scenicId: String, // 景区ID + + /** + * 文件夹ID + */ + @SerializedName("folderId") + var folderId: Int, // 文件夹ID + + /** + * 上传后的URL + */ + @SerializedName("fileUrl") + var fileUrl: String? = null, // 上传后的URL + + /** + * 上传状态 + */ + @SerializedName("status") + var status: UploadStatus = UploadStatus.PENDING, // 上传状态 + + /** + * 上传进度(0-100) + */ + @SerializedName("progress") + var progress: Int = 0, // 上传进度(0-100) + + /** + * 已上传大小,用于断点续传 + */ + @SerializedName("uploadedSize") + var uploadedSize: Long = 0, // 已上传大小,用于断点续传 + + /** + * 创建时间 + */ + @SerializedName("createdAt") + var createdAt: Long = System.currentTimeMillis(), // 创建时间 + + /** + * 更新时间 + */ + @SerializedName("updatedAt") + var updatedAt: Long = System.currentTimeMillis(), // 更新时间 + + /** + * 标识是相册模块的上传 还是素材上传 album:相册, material:素材,task:任务,order:任务 + */ + @SerializedName("moduleType") + var moduleType: String? = null, // 标识是相册模块的上传 + + + /** + * 上传后的URL + */ + @SerializedName("coverUrl") + var coverUrl: String? = null, // 视频封面URL + + + + /** + * 停用 + */ + var bucket: String? = null, + + + /** + * 停用 + */ + var errorMessage: String? = null, + + + ) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/GetPayUrlRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/GetPayUrlRequest.kt new file mode 100644 index 0000000..f9d7ddb --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/GetPayUrlRequest.kt @@ -0,0 +1,11 @@ +package com.yzx.kiosk.network.model.request + +import com.google.gson.annotations.SerializedName + +data class GetPayUrlRequest( + @SerializedName("type") + val type: Int, + @SerializedName("image_id") + val imageId: List +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/PrintCompleteRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/PrintCompleteRequest.kt new file mode 100644 index 0000000..33eac4a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/PrintCompleteRequest.kt @@ -0,0 +1,12 @@ +package com.yzx.kiosk.network.model.request + +import com.google.gson.annotations.SerializedName + +data class PrintCompleteRequest( + @SerializedName("capture_type") + val captureType: Int, + + @SerializedName("order_number") + val orderNumber: String +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/PrintNotifyRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/PrintNotifyRequest.kt new file mode 100644 index 0000000..1f7e5f6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/PrintNotifyRequest.kt @@ -0,0 +1,41 @@ +package com.yzx.kiosk.network.model.request + +import com.google.gson.annotations.SerializedName + +/** + * 打印状态通知请求 + * + * 注意:此处的 print_status 表示订单打印结果,与心跳包中的 print_status 含义不同: + * - 1: 打印成功 + * - 2: 打印失败 + * + * 心跳包中的 print_status 表示打印机状态: + * - 1: 可以接受打印任务 + * - 2: 正在打印,无法接受打印任务 + * - 3: 打印机离线,无法接受打印任务 + */ +data class PrintNotifyRequest( + @SerializedName("capture_type") + val captureType: Int, + + @SerializedName("order_number") + val orderNumber: String, + + @SerializedName("image_id") + val imageId: Int, + + /** + * 打印状态(订单打印结果) + * 1: 打印成功 + * 2: 打印失败 + */ + @SerializedName("print_status") + val printStatus: Int, + + /** + * 剩余纸张数 + */ + @SerializedName("remaining_paper_num") + val remainingPaperNum: Int? = null +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/SaveAlbumUrlRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/SaveAlbumUrlRequest.kt new file mode 100644 index 0000000..d62f7ea --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/SaveAlbumUrlRequest.kt @@ -0,0 +1,12 @@ +package com.yzx.kiosk.network.model.request + +import com.google.gson.annotations.SerializedName + +/** + * 保存相册URL请求 + */ +data class SaveAlbumUrlRequest( + @SerializedName("order_number") + val orderNumber: String +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/SetConfigRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/SetConfigRequest.kt new file mode 100644 index 0000000..4923bbb --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/SetConfigRequest.kt @@ -0,0 +1,21 @@ +package com.yzx.kiosk.network.model.request + +import com.google.gson.annotations.SerializedName + +data class SetConfigRequest( + @SerializedName("phone") + val phone: String, + @SerializedName("posters") + val posters: List, // 已选择的海报 + @SerializedName("all_posters") + val allPosters: List, // 所有在显示列表的海报(posters + all_posters + 新上传的海报的并集) + @SerializedName("bgm") + val bgm: String, // 选择的提示音 + @SerializedName("all_bgm") + val allBgm: List, // 所有在显示列表的提示音(bgm + all_bgm + 新上传的提示音的并集) + @SerializedName("service_phone") + val servicePhone: String? = null, // 首页客服电话 + @SerializedName("is_show_QR_code") + val isShowQRCode: Boolean? = null // 是否显示隐私取片二维码 +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/VerifyResultRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/VerifyResultRequest.kt new file mode 100644 index 0000000..2681930 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/VerifyResultRequest.kt @@ -0,0 +1,11 @@ +package com.yzx.kiosk.network.model.request + +import com.google.gson.annotations.SerializedName + +data class VerifyResultRequest( + @SerializedName("type") + val type: Int, + @SerializedName("image_id") + val imageId: List +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/AliyunOSSResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/AliyunOSSResponse.kt new file mode 100644 index 0000000..05f500e --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/AliyunOSSResponse.kt @@ -0,0 +1,50 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName +import com.yzx.kiosk.network.model.entity.CredentialsEntity + +data class AliyunOSSResponse ( + + + /** + * 传给服务端的URI域名 + * OSS(对象存储服务)的访问域名,用于指定访问的区域和服务。 + */ + @SerializedName("base_url") + val baseUrl: String, + + + /** + * 访问域名 + * OSS(对象存储服务)的访问域名,用于指定访问的区域和服务。 + */ + @SerializedName("endpoint") + val endpoint: String, + + /** + * 存储桶区域 + */ + @SerializedName("region") + val region: String, + + /** + * OSS存储桶名称 + */ + @SerializedName("bucket") + val bucket: String, + + + /** + * 有效时长,单位秒 + */ + @SerializedName("expire_seconds") + val expire_seconds: Int, + + + /** + * 临时访问凭证的信息 + */ + @SerializedName("credentials") + val credentials: CredentialsEntity, + +) diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/BatchQueryResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/BatchQueryResponse.kt new file mode 100644 index 0000000..f54e182 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/BatchQueryResponse.kt @@ -0,0 +1,35 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class BatchQueryResponse( + @SerializedName("success") + val success: Boolean, + + @SerializedName("count") + val count: Int, + + @SerializedName("images") + val images: List? +) + +data class ImageInfo( + @SerializedName("id") + val id: Int, + + @SerializedName("thumbnail_oss_url") + val thumbnailOssUrl: String?, + + @SerializedName("thumbnail_lan_url") + val thumbnailLanUrl: String?, + + @SerializedName("is_uploaded_oss") + val isUploadedOss: Boolean, + + @SerializedName("original_oss_url") + val originalOssUrl: String?, + + @SerializedName("original_lan_url") + val originalLanUrl: String? +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/EquipmentConfigResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/EquipmentConfigResponse.kt new file mode 100644 index 0000000..e7f37c6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/EquipmentConfigResponse.kt @@ -0,0 +1,87 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class EquipmentConfigResponse( + @SerializedName("sn") + val sn: String, + @SerializedName("name") + val name: String, + @SerializedName("type") + val type: Int, + @SerializedName("scenic_id") + val scenicId: Int, + @SerializedName("owner_name") + val ownerName: String?, + @SerializedName("owner_mobile") + val ownerMobile: String?, + @SerializedName("image") + val image: String?, + @SerializedName("remark") + val remark: String?, + @SerializedName("bind_box_id") + val bindBoxId: Int?, + @SerializedName("posters") + val posters: List?, // 已选择的海报列表(字符串数组) + @SerializedName("all_posters") + val allPosters: List?, // 所有可用的海报列表 + @SerializedName("bgm") + val bgm: String?, // 已选择的提示音 + @SerializedName("all_bgm") + val allBgm: List?, // 所有可用的提示音列表 + @SerializedName("service_tel") + val serviceTel: String?, + @SerializedName("bind_kiosk_id") + val bindKioskId: Int?, + @SerializedName("created_at") + val createdAt: String?, + @SerializedName("updated_at") + val updatedAt: String?, + @SerializedName("qrcode") + val qrcode: QrcodeConfig?, + @SerializedName("bind_box_url") + val bindBoxUrl: String?, + @SerializedName("bind_box_lan_url") + val bindBoxLanUrl: String?, + @SerializedName("bind_box_sn") + val bindBoxSn: String?, + @SerializedName("bind_box_api_token") + val bindBoxApiToken: String?, + @SerializedName("bind_box_lan_ip") + val bindBoxLanIp: String?, + @SerializedName("scenic_name") + val scenicName: String?, + @SerializedName("global_config") + val globalConfig: GlobalConfig?, + @SerializedName("sun_qr_url") + val sunQrUrl: String?, + @SerializedName("service_phone") + val servicePhone: String?, // 首页客服电话 + @SerializedName("is_show_QR_code") + val isShowQRCode: Boolean? // 是否显示隐私取片二维码 +) + +data class PosterItem( + @SerializedName("url") + val url: String, + @SerializedName("type") + val type: String +) + +data class GlobalConfig( + @SerializedName("bottom_video") + val bottomVideo: String?, + @SerializedName("button_print") + val buttonPrint: String? + +) + +data class QrcodeConfig( + @SerializedName("scan_upload_url") + val scanUploadUrl: String?, + @SerializedName("scan_pay_url") + val scanPayUrl: String?, + @SerializedName("scan_share_url") + val scanShareUrl: String? +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/FaceSearchResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/FaceSearchResponse.kt new file mode 100644 index 0000000..8eacd79 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/FaceSearchResponse.kt @@ -0,0 +1,24 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class FaceSearchResponse( + @SerializedName("count") + val count: Int, + @SerializedName("results") + val results: List? +) + +data class FaceSearchResult( + @SerializedName("id") + val id: Int, + @SerializedName("thumbnail_oss_url") + val thumbnailOssUrl: String?, + @SerializedName("thumbnail_lan_url") + val thumbnailLanUrl: String?, + @SerializedName("original_lan_url") + val originalLanUrl: String?, + @SerializedName("original_oss_url") + val originalOssUrl: String? +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/GetCodeUrlResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/GetCodeUrlResponse.kt new file mode 100644 index 0000000..bb4add3 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/GetCodeUrlResponse.kt @@ -0,0 +1,9 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class GetCodeUrlResponse( + @SerializedName("url") + val url: String? +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/GetPayUrlResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/GetPayUrlResponse.kt new file mode 100644 index 0000000..516a443 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/GetPayUrlResponse.kt @@ -0,0 +1,9 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class GetPayUrlResponse( + @SerializedName("url") + val url: String? +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/GetPrintInfoResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/GetPrintInfoResponse.kt new file mode 100644 index 0000000..b394e03 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/GetPrintInfoResponse.kt @@ -0,0 +1,17 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class GetPrintInfoResponse( + @SerializedName("printer_photo_size") + val printerPhotoSize: String?, + @SerializedName("price_photo_print") + val pricePhotoPrint: String?, + @SerializedName("estimated_time") + val estimatedTime: String?, + @SerializedName("printer_type") + val printerType: String?, + @SerializedName("customer_service_phone") + val customerServicePhone: String? +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/MsgResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/MsgResponse.kt new file mode 100644 index 0000000..c0b711b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/MsgResponse.kt @@ -0,0 +1,19 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName +import com.yzx.kiosk.network.model.entity.MsgEntity + +data class MsgResponse ( + + @SerializedName("has_more") + val has_more: Boolean, + + @SerializedName("last_id") + val last_id: Int, + + @SerializedName("items") + val items: List = emptyList(), + + @SerializedName("is_refresh") + var isRefresh : Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/NetworkResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/NetworkResponse.kt new file mode 100644 index 0000000..20a647c --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/NetworkResponse.kt @@ -0,0 +1,19 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class NetworkResponse( + @SerializedName("data") + val data: T? = null, + @SerializedName("code") + val code: Int = 0, + @SerializedName("msg") + val message: String? = null, +) { + val isSucceeded: Boolean + get() = code == 100000 + + val isTokenFailed: Boolean + get() = code == 180024 +} + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/OscarLivesResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/OscarLivesResponse.kt new file mode 100644 index 0000000..69c9415 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/OscarLivesResponse.kt @@ -0,0 +1,64 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +/** GET /api/oscar/lives?format=rtmp */ +data class OscarLivesData( + @SerializedName("sdk_params") + val sdkParams: OscarLivesSdkParams? = null, + @SerializedName("scenic") + val scenic: OscarLivesScenic? = null, + @SerializedName("lives") + val lives: List? = null +) + +data class OscarLivesSdkParams( + @SerializedName("app") + val app: String? = null, + @SerializedName("key") + val key: String? = null +) + +data class OscarLivesScenic( + @SerializedName("id") + val id: Int? = null, + @SerializedName("name") + val name: String? = null, + @SerializedName("short_name") + val shortName: String? = null +) + +data class OscarLiveItem( + @SerializedName("id") + val id: Int? = null, + @SerializedName("title") + val title: String? = null, + @SerializedName("start_time") + val startTime: String? = null, + @SerializedName("status") + val status: Int? = null, + @SerializedName("status_label") + val statusLabel: String? = null, + @SerializedName("play_urls") + val playUrls: OscarLivePlayUrls? = null, + @SerializedName("source_type") + val sourceType: Int? = null, + @SerializedName("fly_plan_log_id") + val flyPlanLogId: String? = null, + @SerializedName("free_bgm_url") + val freeBgmUrl: String? = null, + @SerializedName("time") + val time: String? = null +) + +/** 直播地址:auto 默认;sm 普清;md 高清;lg 超清 */ +data class OscarLivePlayUrls( + @SerializedName("auto") + val auto: String? = null, + @SerializedName("sm") + val sm: String? = null, + @SerializedName("md") + val md: String? = null, + @SerializedName("lg") + val lg: String? = null +) diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/SocketTokenResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/SocketTokenResponse.kt new file mode 100644 index 0000000..44ed466 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/SocketTokenResponse.kt @@ -0,0 +1,11 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class SocketTokenResponse( + @SerializedName("socket_token") + val socketToken: String, + @SerializedName("expired_at") + val expiredAt: Long +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/VerifyResultResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/VerifyResultResponse.kt new file mode 100644 index 0000000..dc284e8 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/VerifyResultResponse.kt @@ -0,0 +1,11 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName + +data class VerifyResultResponse( + @SerializedName("price_image") + val priceImage: String?, + @SerializedName("amount") + val amount: String? +) + diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/VersionResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/VersionResponse.kt new file mode 100644 index 0000000..461cd42 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/VersionResponse.kt @@ -0,0 +1,48 @@ +package com.yzx.kiosk.network.model.response + +import com.google.gson.annotations.SerializedName +data class VersionResponse ( + /** + * 传给服务端的URI域名 + * OSS(对象存储服务)的访问域名,用于指定访问的区域和服务。 + */ + @SerializedName("id") + val id: Int, + + @SerializedName("type") + val type: Int, + + @SerializedName("version") + val version: String, + + @SerializedName("operator") + val operator: String, + + @SerializedName("description") + val description: String, + + @SerializedName("status") + val status: Int, + + @SerializedName("created_at") + val created_at: String, + + @SerializedName("updated_at") + val updated_at: String, + + @SerializedName("deleted_at") + val deleted_at: String?, + + @SerializedName("version_code") + val version_code: Int, + + @SerializedName("apk_url") + val apk_url: String, + + @SerializedName("is_force_update") + val is_force_update: Int, + + @SerializedName("approve_notes") + val approve_notes: String + +) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/repository/LocationStateRepository.kt b/app/src/main/java/com/yzx/kiosk/network/repository/LocationStateRepository.kt new file mode 100644 index 0000000..648e38b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/repository/LocationStateRepository.kt @@ -0,0 +1,140 @@ +package com.yzx.kiosk.network.repository + +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.status.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocationStateRepository @Inject constructor( + private val appStore: AppStoreDataSource, + @ApplicationScope private val applicationScope: CoroutineScope +) { + private val onlineDurationMillis = 2 * 60 * 60 * 1000L + + // 在线状态 + private val _isOnline = MutableStateFlow(appStore.getOnlineStatus()) + val isOnline: StateFlow = _isOnline.asStateFlow() + + // 已用时间(秒) + private val _elapsedTime = MutableStateFlow(0L) + val elapsedTime: StateFlow = _elapsedTime.asStateFlow() + + // 下次上报倒计时(秒) + private val _nextReportCountdown = MutableStateFlow(0L) + val nextReportCountdown: StateFlow = _nextReportCountdown.asStateFlow() + + // 提醒设置(分钟) + private val _reminderMinutes = MutableStateFlow(appStore.getLocationReminderMinutes()) + val reminderMinutes: StateFlow = _reminderMinutes.asStateFlow() + + // 倒计时任务 + private var countdownJob: Job? = null + private var lastReportTimestamp: Long? = null + + init { + restoreState() + } + + private fun restoreState() { + if (!appStore.getOnlineStatus()) { + return + } + + val lastTime = appStore.getLastLocationReportTime() + if (lastTime <= 0L) { + return + } + + val now = System.currentTimeMillis() + val elapsed = now - lastTime + + if (elapsed >= onlineDurationMillis) { + updateOnlineStatus(false) + appStore.clearLastLocationReportTime() + return + } + + lastReportTimestamp = lastTime + val remaining = onlineDurationMillis - elapsed + val elapsedSeconds = (elapsed / 1000).coerceAtLeast(0) + val remainingSeconds = (remaining / 1000).coerceAtLeast(0) + + _elapsedTime.value = elapsedSeconds + _nextReportCountdown.value = remainingSeconds + + startCountdown(lastTime) + } + + fun updateOnlineStatus(status: Boolean) { + _isOnline.value = status + appStore.saveOnlineStatus(status) + if (!status) { + stopCountdown() + appStore.clearLastLocationReportTime() + } + } + + fun updateReminderMinutes(minutes: Int) { + _reminderMinutes.value = minutes + appStore.saveLocationReminderMinutes(minutes) + } + + fun startLocationReport(timestamp: Long) { + lastReportTimestamp = timestamp + appStore.saveLastLocationReportTime(timestamp) + updateOnlineStatus(true) + _elapsedTime.value = 0L + _nextReportCountdown.value = onlineDurationMillis / 1000 + startCountdown(timestamp) + } + + private fun startCountdown(anchorTime: Long) { + countdownJob?.cancel() + countdownJob = null + + if (!_isOnline.value) return + + countdownJob = applicationScope.launch { + while (isActive && _isOnline.value) { + val now = System.currentTimeMillis() + val elapsed = now - anchorTime + val remaining = onlineDurationMillis - elapsed + val elapsedSeconds = (elapsed / 1000).coerceAtLeast(0) + + _elapsedTime.value = elapsedSeconds + + if (remaining <= 0) { + _nextReportCountdown.value = 0 + updateOnlineStatus(false) + appStore.clearLastLocationReportTime() + break + } else { + val remainingSeconds = (remaining / 1000).coerceAtLeast(0) + _nextReportCountdown.value = remainingSeconds + } + + delay(1000) + } + } + } + + private fun stopCountdown() { + countdownJob?.cancel() + countdownJob = null + lastReportTimestamp = null + _elapsedTime.value = 0 + _nextReportCountdown.value = 0 + } + + fun getLastReportTimestamp(): Long? = lastReportTimestamp +} + diff --git a/app/src/main/java/com/yzx/kiosk/network/repository/NetWorkRepository.kt b/app/src/main/java/com/yzx/kiosk/network/repository/NetWorkRepository.kt new file mode 100644 index 0000000..72973d6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/repository/NetWorkRepository.kt @@ -0,0 +1,100 @@ +package com.yzx.kiosk.network.repository + +import com.yzx.kiosk.network.model.request.GetPayUrlRequest +import com.yzx.kiosk.network.model.request.PrintCompleteRequest +import com.yzx.kiosk.network.model.request.PrintNotifyRequest +import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest +import com.yzx.kiosk.network.model.request.SetConfigRequest +import com.yzx.kiosk.network.model.request.VerifyResultRequest +import com.yzx.kiosk.network.model.response.AliyunOSSResponse +import com.yzx.kiosk.network.model.response.EquipmentConfigResponse +import com.yzx.kiosk.network.model.response.GetCodeUrlResponse +import com.yzx.kiosk.network.model.response.GetPayUrlResponse +import com.yzx.kiosk.network.model.response.GetPrintInfoResponse +import com.yzx.kiosk.network.model.response.NetworkResponse +import com.yzx.kiosk.network.model.response.OscarLivesData +import com.yzx.kiosk.network.model.response.SocketTokenResponse +import com.yzx.kiosk.network.model.response.VerifyResultResponse +import com.yzx.kiosk.network.model.response.VersionResponse +import com.yzx.kiosk.network.service.NetworkService +import com.yzx.kiosk.network.service.UploadService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +class NetWorkRepository @Inject constructor( + private val netService: NetworkService, + private val uploadService: UploadService, +) { + // 基础方法示例,可根据需要添加 + // fun example(): Flow> = flow { + // emit(netService.example()) + // }.flowOn(Dispatchers.IO) + + fun pushUrlByAliyun(scenicid: String, url: String, folderId: Int): Flow> = + flow { + emit(netService.pushUrlByAliyun(scenicid, url, folderId.toString())) + }.flowOn(Dispatchers.IO) + + fun aliyunOSSBucket(bucket: String): Flow> = flow { + emit(netService.aliyunOSSBucket(bucket)) + }.flowOn(Dispatchers.IO) + + fun aliyunStsToken(bucket: String): Flow> = flow { + emit(netService.aliyunStsToken(bucket)) + }.flowOn(Dispatchers.IO) + + fun checkVersion(): Flow> = flow { + emit(netService.checkApkVersion()) + }.flowOn(Dispatchers.IO) + + fun checkLatestVersion(type: Int = 8): Flow> = flow { + emit(netService.checkLatestVersion(type)) + }.flowOn(Dispatchers.IO) + + fun getEquipmentConfig(): Flow> = flow { + emit(netService.getEquipmentConfig()) + }.flowOn(Dispatchers.IO) + + fun getOscarLives(format: String = "rtmp"): Flow> = flow { + emit(netService.getOscarLives(format)) + }.flowOn(Dispatchers.IO) + + fun setConfig(request: SetConfigRequest): Flow> = flow { + emit(netService.setConfig(request)) + }.flowOn(Dispatchers.IO) + + fun getCodeUrl(): Flow> = flow { + emit(netService.getCodeUrl()) + }.flowOn(Dispatchers.IO) + + fun getPrintInfo(): Flow> = flow { + emit(netService.getPrintInfo()) + }.flowOn(Dispatchers.IO) + + fun verifyResult(request: VerifyResultRequest): Flow> = flow { + emit(netService.verifyResult(request)) + }.flowOn(Dispatchers.IO) + + fun getPayUrl(request: GetPayUrlRequest): Flow> = flow { + emit(netService.getPayUrl(request)) + }.flowOn(Dispatchers.IO) + + fun getSocketToken(): Flow> = flow { + emit(netService.getSocketToken()) + }.flowOn(Dispatchers.IO) + + fun printNotify(request: PrintNotifyRequest): Flow> = flow { + emit(netService.printNotify(request)) + }.flowOn(Dispatchers.IO) + + fun printComplete(request: PrintCompleteRequest): Flow> = flow { + emit(netService.printComplete(request)) + }.flowOn(Dispatchers.IO) + + fun saveAlbumUrl(request: SaveAlbumUrlRequest): Flow> = flow { + emit(netService.saveAlbumUrl(request)) + }.flowOn(Dispatchers.IO) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/repository/TransferRepository.kt b/app/src/main/java/com/yzx/kiosk/network/repository/TransferRepository.kt new file mode 100644 index 0000000..0e570a7 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/repository/TransferRepository.kt @@ -0,0 +1,138 @@ +package com.yzx.kiosk.network.repository + +import com.yzx.kiosk.network.enums.UploadStatus +import com.yzx.kiosk.network.model.entity.UploadTaskEntity +import com.yzx.kiosk.network.datasource.LocalDataSource +import jakarta.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Singleton + + +@Singleton +class TransferRepository @Inject constructor( + private val localDataSource: LocalDataSource, + private val applicationScope: CoroutineScope +) { + // 用注入的Scope替代viewModelScope + private val repoScope = applicationScope + private val _tasksAddedEvent = MutableStateFlow>(emptyList()) + val tasksAddedEvent: StateFlow> = _tasksAddedEvent.asStateFlow() + + // 定义上传控制指令 + private val _startUploadCommands = MutableSharedFlow() // 发送taskId + val startUploadCommands: SharedFlow = _startUploadCommands.asSharedFlow() + + private val _cancelUploadCommands = MutableSharedFlow() + val cancelUploadCommands: SharedFlow = _cancelUploadCommands.asSharedFlow() + + private val _retryUploadCommands = MutableSharedFlow() + val retryUploadCommands: SharedFlow = _retryUploadCommands.asSharedFlow() + + // 带 replay 的完成事件 + private val _uploadCompletedEventTwo = MutableSharedFlow(replay = 1) + val uploadCompletedEventTwo = _uploadCompletedEventTwo.asSharedFlow() + + // 兼容保留旧的事件 + private val _uploadCompletedEvent = MutableSharedFlow() + val uploadCompletedEvent = _uploadCompletedEvent.asSharedFlow() + + + /** + * 添加任务队列的操作方法 + * @param tasks 需要添加的任务列表 + */ + suspend fun addTasksToQueue(tasks: List) { + _tasksAddedEvent.value = tasks // 只发事件,不再直写本地库 + } + + /** + * 发送上传完成事件 + * @param folderId 文件夹ID + */ + suspend fun sendUploadCompleted(folderId: String) { + _uploadCompletedEvent.emit(folderId) + _uploadCompletedEventTwo.emit(folderId) // 确保 Two 也能收到 + } + + + /** + * 启动上传 + * @param taskId 任务ID + */ + fun startUpload(taskId: String) { + // 通知TransferViewModel启动指定任务 + repoScope.launch { // 注意:需要引入CoroutineScope,见步骤3 + _startUploadCommands.emit(taskId) + } + + } + + /** + * 取消上传任务 + * @param taskId 任务ID + */ + fun cancelUpload(taskId: String) { + repoScope.launch { + _cancelUploadCommands.emit(taskId) + } + } + + /** + * 重试上传任务 + * @param taskId 任务ID + */ + suspend fun retryUpload(taskId: String) { + // 实现重试逻辑 + // 1. 从本地数据源获取任务 + val task = localDataSource.getUploadTaskById(taskId) + if (task != null) { + //2. 检查任务是否已在传输中 + if (task.status == UploadStatus.UPLOADING) { + return + } + // 3. 更新任务状态为待上传 + localDataSource.updateUploadTaskStatus(task.copy( + status = UploadStatus.PENDING, + progress = 0 + )) + // 4. 通知TransferViewModel重新加入队列并启动 + _retryUploadCommands.emit(taskId) + + } + } + + /** + * 观察所有上传任务 + * @return 所有上传任务的Flow + */ + fun observeAllUploadTasks(): Flow> { + return localDataSource.observeAllUploadTasks() + //return transferViewModel.uploadTasks + } + + /** + * 根据任务ID获取上传任务 + * @param taskId 任务ID + * @return 上传任务对象 + */ + suspend fun getUploadTaskById(taskId: String): UploadTaskEntity? { + return withContext(Dispatchers.IO) { + // 实现从数据库或内存中查找任务的逻辑 + // 这里需要根据你的实际实现来编写 + localDataSource.getUploadTaskById(taskId) + } + } + + + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/result/GlobalMessageHandler.kt b/app/src/main/java/com/yzx/kiosk/network/result/GlobalMessageHandler.kt new file mode 100644 index 0000000..71d7868 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/result/GlobalMessageHandler.kt @@ -0,0 +1,47 @@ +package com.yzx.kiosk.network.result + +import android.app.ActivityManager +import android.content.Context +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import com.yzx.kiosk.datastore.AppStoreDataSource +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.Serializable +import javax.inject.Singleton + + +@Singleton +class GlobalMessageHandler @Inject constructor( + private val appStoreDataSource: AppStoreDataSource, + @ApplicationContext private val context: Context +) { + private val _messages = MutableSharedFlow() + val messages = _messages.asSharedFlow() + + fun handleMessage(message: PushMessage) { + CoroutineScope(Dispatchers.Main).launch { + _messages.emit(message) + } + } + + // 消息数据类 + data class PushMessage( + val type: String, + val title: String, + val content: String, + val extras: Map = emptyMap(), + val timestamp: Long = System.currentTimeMillis() + ) : Serializable + + // 检查应用是否在前台 + fun isAppInForeground(): Boolean { + val appProcessInfo = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(appProcessInfo) + return appProcessInfo.importance == + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/result/PagingResultHandler.kt b/app/src/main/java/com/yzx/kiosk/network/result/PagingResultHandler.kt new file mode 100644 index 0000000..06069a5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/result/PagingResultHandler.kt @@ -0,0 +1,154 @@ +package com.yzx.kiosk.network.result + +import androidx.paging.PagingSource +import com.yzx.kiosk.network.model.entity.MsgEntity +import com.yzx.kiosk.network.model.response.MsgResponse +import com.yzx.kiosk.network.model.response.NetworkResponse +import com.yzx.kiosk.utils.LogUtils + +object PagingResultHandler { + + suspend fun handlePagingResult( + call: suspend () -> Result>, + onSuccess: (T) -> PagingSource.LoadResult, + onError: (Throwable) -> PagingSource.LoadResult + ): PagingSource.LoadResult { + return try { + LogUtils.d("开始处理分页请求...") + + // 执行网络请求 + val result = call() + LogUtils.d( "收到网络请求结果: ${result::class.simpleName}") + + when (result) { + is Result.Loading -> { + LogUtils.d("请求仍在加载中,等待最终结果...") + // 对于分页加载,我们等待最终结果,所以这里不做任何操作 + // 实际实现中,我们可以选择等待或返回加载状态 + handlePagingResult(call, onSuccess, onError) + } + + is Result.Progress -> { + LogUtils.d("请求进度更新: ${result.percent}%") + // 对于分页加载,进度更新可能不重要,继续等待最终结果 + handlePagingResult(call, onSuccess, onError) + } + + is Result.Success -> { + LogUtils.d("请求成功,开始解析响应数据...") + val response = result.data + + // 打印完整的响应信息用于调试 + LogUtils.d( "完整响应: $response") + + when { + response.isSucceeded && response.data != null -> { + LogUtils.d("业务逻辑成功,数据可用") + LogUtils.d("数据类型: ${response.data!!::class.java.simpleName}") + + // 分步解析数据 - 临时测试使用 + response.data?.let { data -> + LogUtils.d( "=== 开始分步解析数据 ===") + LogUtils.d("1. 解析基础字段") + LogUtils.d(" - code: ${response.code}") + LogUtils.d(" - message: ${response.message}") + LogUtils.d(" - isSucceeded: ${response.isSucceeded}") + LogUtils.d(" - isTokenFailed: ${response.isTokenFailed}") + + LogUtils.d( "2. 解析数据体") + when (data) { + is MsgResponse -> { + LogUtils.d("3. 解析消息响应体") + LogUtils.d(" - items 数量: ${data.items.size}") + LogUtils.d(" - has_more: ${data.has_more}") + LogUtils.d(" - last_id: ${data.last_id}") + + // 可以添加更多详细解析... + if (data.items.isNotEmpty()) { + LogUtils.d( "4. 第一条消息详情:") + val firstMsg = data.items[0] + LogUtils.d(" - id: ${firstMsg.id}") + LogUtils.d( " - title: ${firstMsg.title}") + LogUtils.d( " - content: ${firstMsg.content}") + LogUtils.d( " - created_at: ${firstMsg.push_at}") + } + } + else -> { + LogUtils.d( "未知数据类型: ${data::class.java.name}") + } + } + LogUtils.d( "=== 数据解析完成 ===") + } + + onSuccess(response.data) + } + response.isTokenFailed -> { + LogUtils.e("Token失效错误: ${response.message}") + onError(Exception("Token失效: ${response.message}")) + } + else -> { + val errorMsg = response.message ?: "未知业务错误" + LogUtils.e("业务逻辑错误: $errorMsg") + LogUtils.e("错误详情: code=${response.code}, message=${response.message}") + onError(Exception("业务错误: $errorMsg")) + } + } + } + + is Result.Error -> { + // 详细错误处理 + LogUtils.e("网络请求错误: ${result.exception.message}", result.exception) + + // 打印完整的异常堆栈 + LogUtils.e("异常堆栈:") + result.exception.printStackTrace() + + // 根据异常类型细化错误信息 + val detailedError = when (result.exception) { + is java.net.UnknownHostException -> "网络不可用,请检查网络连接" + is java.net.SocketTimeoutException -> "请求超时,请稍后重试" + is javax.net.ssl.SSLHandshakeException -> "安全连接失败" + else -> result.exception.message ?: "未知网络错误" + } + + LogUtils.e("详细错误信息: $detailedError") + onError(Exception(detailedError)) + } + + else -> { + LogUtils.e("未处理的Result类型: ${result::class.simpleName}") + onError(Exception("未处理的响应类型")) + } + } + } catch (e: Exception) { + LogUtils.e("处理分页结果时发生异常: ${e.message}", e) + onError(e) + } + } +} + + + + + + + + suspend fun handlePagingResult2( + call: suspend () -> Result>, + onSuccess: (T) -> PagingSource.LoadResult, + onError: (Throwable) -> PagingSource.LoadResult + ): PagingSource.LoadResult { + return when (val result = call()) { + is Result.Success -> { + val response = result.data + when { + response.isSucceeded && response.data != null -> + onSuccess(response.data) + else -> onError(Exception(response.message ?: "Unknown error")) + } + } + is Result.Error -> onError(result.exception) + else -> onError(Exception("Unexpected result type")) + } + } + diff --git a/app/src/main/java/com/yzx/kiosk/network/result/Result.kt b/app/src/main/java/com/yzx/kiosk/network/result/Result.kt new file mode 100644 index 0000000..f2684a9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/result/Result.kt @@ -0,0 +1,12 @@ +package com.yzx.kiosk.network.result + +sealed interface Result { + + data object Loading : Result + + data class Success(val data: T) : Result + + data class Error(val exception: Throwable) : Result + + data class Progress(val percent: Int) : Result +} diff --git a/app/src/main/java/com/yzx/kiosk/network/result/ResultExt.kt b/app/src/main/java/com/yzx/kiosk/network/result/ResultExt.kt new file mode 100644 index 0000000..a36070a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/result/ResultExt.kt @@ -0,0 +1,10 @@ +package com.yzx.kiosk.network.result + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart + +fun Flow.asResult(): Flow> = map> { Result.Success(it) } + .onStart { emit(Result.Loading) } + .catch { emit(Result.Error(it)) } \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt b/app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt new file mode 100644 index 0000000..27e2741 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt @@ -0,0 +1,101 @@ +@file:Suppress("UNCHECKED_CAST") + +package com.yzx.kiosk.network.result + +import android.util.Log +import com.hjq.toast.Toaster +import com.yzx.kiosk.network.model.response.NetworkResponse +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object ResultHandler { + + private fun handleResult( + scope: CoroutineScope, + flow: Flow>>, + showToast: Boolean = true, + onLoading: () -> Unit = {}, + onSuccess: (NetworkResponse) -> Unit = {}, + onSuccessWithData: (T) -> Unit = {}, + onError: (String, Throwable?) -> Unit = { _, _ -> }, + onEnd: () -> Unit = {}, + onTokenFailed: () -> Unit = {}, + onProgress: (Int) -> Unit = {}, + ) { + scope.launch { + flow.collectLatest { result -> + when (result) { + is Result.Loading -> { + onLoading() + } + + is Result.Progress -> { + onProgress(result.percent) + } + + is Result.Success -> { + val response = result.data + onSuccess(response) + + if (response.isSucceeded) { + onSuccessWithData(response.data ?: Unit as T) + } else if (response.isTokenFailed) { + onTokenFailed() + } else { + val errorMsg = response.message + ?: "请求失败,请重试!" + onError(errorMsg, Exception(errorMsg)) + + if (showToast) { + Toaster.show(errorMsg) + } + } + onEnd() + } + + is Result.Error -> { + val errorMsg = + result.exception.message + ?: "请求失败,请重试!" + onError(errorMsg, result.exception) + + if (showToast) { + Log.i("ResultHandler", errorMsg) + //Toaster.show(errorMsg) 20250721 + } + + onEnd() + } + } + } + } + } + + fun handleResultWithData( + scope: CoroutineScope, + flow: Flow>>, + showToast: Boolean = true, + onLoading: () -> Unit = {}, + onDataResponse: (NetworkResponse) -> Unit = {}, + onData: (T) -> Unit, + onError: (String, Throwable?) -> Unit = { _, _ -> }, + onEnd: () -> Unit = {}, + onTokenFailed: () -> Unit = {}, + onProgress: (Int) -> Unit = {}, + ) { + handleResult( + scope = scope, + flow = flow, + showToast = showToast, + onLoading = onLoading, + onSuccessWithData = onData, + onSuccess = onDataResponse, + onError = onError, + onEnd = onEnd, + onTokenFailed = onTokenFailed, + onProgress = onProgress, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt new file mode 100644 index 0000000..b960aa7 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt @@ -0,0 +1,24 @@ +package com.yzx.kiosk.network.service + +import com.yzx.kiosk.network.model.response.FaceSearchResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.Response +import retrofit2.http.Header +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path + +interface FaceSearchService { + @Multipart + @POST("/{sn}/api/search") + suspend fun searchFace( + @Path("sn") sn: String, + @Header("Authorization") authorization: String, + @Part image: MultipartBody.Part, + @Part("threshold") threshold: RequestBody?, + @Part("index_date") indexDate: RequestBody? + ): Response +} + diff --git a/app/src/main/java/com/yzx/kiosk/network/service/NetworkService.kt b/app/src/main/java/com/yzx/kiosk/network/service/NetworkService.kt new file mode 100644 index 0000000..43967bd --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/service/NetworkService.kt @@ -0,0 +1,83 @@ +package com.yzx.kiosk.network.service + +import com.yzx.kiosk.network.model.request.GetPayUrlRequest +import com.yzx.kiosk.network.model.request.PrintCompleteRequest +import com.yzx.kiosk.network.model.request.PrintNotifyRequest +import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest +import com.yzx.kiosk.network.model.request.SetConfigRequest +import com.yzx.kiosk.network.model.request.VerifyResultRequest +import com.yzx.kiosk.network.model.response.AliyunOSSResponse +import com.yzx.kiosk.network.model.response.EquipmentConfigResponse +import com.yzx.kiosk.network.model.response.GetCodeUrlResponse +import com.yzx.kiosk.network.model.response.GetPayUrlResponse +import com.yzx.kiosk.network.model.response.GetPrintInfoResponse +import com.yzx.kiosk.network.model.response.NetworkResponse +import com.yzx.kiosk.network.model.response.OscarLivesData +import com.yzx.kiosk.network.model.response.SocketTokenResponse +import com.yzx.kiosk.network.model.response.VerifyResultResponse +import com.yzx.kiosk.network.model.response.VersionResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface NetworkService { + // 基础接口示例,可根据需要添加 + // @POST("/api/example") + // suspend fun example(@Body req: Any): NetworkResponse + @GET("/api/oscar/config/get-sts-token") + suspend fun aliyunOSSBucket( + @Query("bucket") bucket: String, + ): NetworkResponse + + @GET("/api/oscar/config/get-sts-token") + suspend fun aliyunStsToken( + @Query("bucket") bucket: String, + ): NetworkResponse + + @POST("/api/yf-handset-app/photog/album/file-upload-url") + suspend fun pushUrlByAliyun( + @Query("scenic_id") scenicId: String, + @Query("file_url") url: String? = null, + @Query("folder_id") folderId: String? = null + ): NetworkResponse + + @GET("/api/app/latest-version") + suspend fun checkApkVersion(): NetworkResponse + + @GET("/api/version/latest") + suspend fun checkLatestVersion(@Query("type") type: Int): NetworkResponse + + @GET("/api/oscar/config/get") + suspend fun getEquipmentConfig(): NetworkResponse + + @GET("/api/oscar/lives") + suspend fun getOscarLives(@Query("format") format: String): NetworkResponse + + @POST("/api/oscar/config/set") + suspend fun setConfig(@Body request: SetConfigRequest): NetworkResponse + + @GET("/api/oscar/photo/get-code-url") + suspend fun getCodeUrl(): NetworkResponse + + @GET("/api/oscar/photo/get-print-info") + suspend fun getPrintInfo(): NetworkResponse + + @POST("/api/oscar/order/verify-result") + suspend fun verifyResult(@Body request: VerifyResultRequest): NetworkResponse + + @POST("/api/oscar/order/get-pay-url") + suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse + + @GET("/api/oscar/socket-token") + suspend fun getSocketToken(): NetworkResponse + + @POST("/api/oscar/order/print-notify") + suspend fun printNotify(@Body request: PrintNotifyRequest): NetworkResponse + + @POST("/api/oscar/order/print-complete") + suspend fun printComplete(@Body request: PrintCompleteRequest): NetworkResponse + + @POST("/api/oscar/order/save-album-url") + suspend fun saveAlbumUrl(@Body request: SaveAlbumUrlRequest): NetworkResponse +} diff --git a/app/src/main/java/com/yzx/kiosk/network/service/UploadService.kt b/app/src/main/java/com/yzx/kiosk/network/service/UploadService.kt new file mode 100644 index 0000000..55fa2ad --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/service/UploadService.kt @@ -0,0 +1,41 @@ +package com.yzx.kiosk.network.service + +import com.yzx.kiosk.network.model.response.NetworkResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.Call +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +interface UploadService { + @Multipart + @POST("/api/yf-handset-app/photog/userinfo-update-avatar") + suspend fun uploadPhotographerAvatar(@Part file: MultipartBody.Part): NetworkResponse + + @Multipart + @POST("/api/yf-handset-app/photog/album/file-upload") + suspend fun uploadPhotographerAlbumFile( + @Part file: MultipartBody.Part, + @Part("scenic_id") scenicId: RequestBody, + @Part("folder_id") folderId: RequestBody, + ): NetworkResponse + + @Multipart + @POST("/api/yf-handset-app/photog/album/file-upload") + fun uploadPhotographerAlbumFileCall( + @Part file: MultipartBody.Part, + @Part("scenic_id") scenicId: RequestBody, + @Part("folder_id") folderId: RequestBody, + ): Call> + + + @Multipart + @POST("/api/yf-handset-app/photog/album/file-upload") + fun uploadPhotographerMaterialFileCall( + @Part file: MultipartBody.Part, + @Part("scenic_id") scenicId: RequestBody, + @Part("folder_id") folderId: RequestBody, + ): Call> + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/priter/PRINTSIZE.kt b/app/src/main/java/com/yzx/kiosk/priter/PRINTSIZE.kt new file mode 100644 index 0000000..55fd900 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/priter/PRINTSIZE.kt @@ -0,0 +1,11 @@ +package com.yzx.kiosk.priter + +enum class PRINTSIZE( + val width: Int, + val height: Int, + val desc: String +) { + RX1(1840, 1240, "RX1 宽度 1840, 高度 1240"), + QW410(1266, 1836, "QW410 宽度 1266, 高度 1836"), + SEAORY(1012, 648, "卡片打印机 宽度 1012, 高度 648") +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/priter/PrintRecord.kt b/app/src/main/java/com/yzx/kiosk/priter/PrintRecord.kt new file mode 100644 index 0000000..e41063a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/priter/PrintRecord.kt @@ -0,0 +1,12 @@ +package com.yzx.kiosk.priter + +/** + * 打印记录类 + */ +data class PrintRecord( + val fileName: String, // 文件名 + val printEndTime: Long, // 打印结束时间(时间戳) + val isSuccess: Boolean, // 打印成功或失败的标记 + val message: String = "" // 附加信息(如失败原因) +) + diff --git a/app/src/main/java/com/yzx/kiosk/priter/PrintStatusManager.kt b/app/src/main/java/com/yzx/kiosk/priter/PrintStatusManager.kt new file mode 100644 index 0000000..af9b4e8 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/priter/PrintStatusManager.kt @@ -0,0 +1,109 @@ +package com.yzx.kiosk.priter + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 打印状态管理器 + * 用于管理全局打印状态,供 WebSocket 心跳包使用 + * + * 注意:打印机在线状态和打印状态分开管理,避免混用 + */ +@Singleton +class PrintStatusManager @Inject constructor() { + companion object { + // 打印状态枚举(用于心跳包) + const val STATUS_IDLE = 1 // 可以接受打印任务(在线且空闲) + const val STATUS_PRINTING = 2 // 正在打印,无法接受打印任务(在线且打印中) + const val STATUS_OFFLINE = 3 // 打印机离线,无法接受打印任务 + } + + // 打印状态:空闲或正在打印(仅当在线时有效) + private val _printStatus = MutableStateFlow(STATUS_IDLE) + val printStatus: StateFlow = _printStatus.asStateFlow() + + // 打印机在线状态:单独管理,避免与打印状态混用 + private val _isOnline = MutableStateFlow(true) // 默认假设在线 + val isOnline: StateFlow = _isOnline.asStateFlow() + + /** + * 设置打印状态(仅当在线时有效) + * @param status STATUS_IDLE 或 STATUS_PRINTING + */ + fun setPrintStatus(status: Int) { + if (status == STATUS_IDLE || status == STATUS_PRINTING) { + _printStatus.value = status + } + } + + /** + * 获取当前打印状态(用于心跳包) + * 返回值: + * - 1: 可以接受打印任务(在线且空闲) + * - 2: 正在打印,无法接受打印任务(在线且打印中) + * - 3: 打印机离线,无法接受打印任务 + */ + fun getPrintStatus(): Int { + // 如果离线,直接返回离线状态 + if (!_isOnline.value) { + return STATUS_OFFLINE + } + // 如果在线,返回当前打印状态(空闲或打印中) + return _printStatus.value + } + + /** + * 设置为正在打印(仅当在线时有效) + */ + fun setPrinting() { + if (_isOnline.value) { + _printStatus.value = STATUS_PRINTING + } + } + + /** + * 设置为空闲(可以接受打印任务,仅当在线时有效) + */ + fun setIdle() { + if (_isOnline.value) { + _printStatus.value = STATUS_IDLE + } + } + + /** + * 设置打印机在线状态 + * @param online true-在线, false-离线 + */ + fun setOnline(online: Boolean) { + _isOnline.value = online + if (online) { + // 上线时,默认设置为空闲状态 + _printStatus.value = STATUS_IDLE + } + } + + /** + * 设置为离线(兼容旧代码) + */ + fun setOffline() { + setOnline(false) + } + + /** + * 检查打印机是否在线 + */ + fun isOnline(): Boolean { + return _isOnline.value + } + + /** + * 检查打印机是否离线 + */ + fun isOffline(): Boolean { + return !_isOnline.value + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/priter/PrinterModule.kt b/app/src/main/java/com/yzx/kiosk/priter/PrinterModule.kt new file mode 100644 index 0000000..567669b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/priter/PrinterModule.kt @@ -0,0 +1,26 @@ +package com.yzx.kiosk.priter + +import android.content.Context +import com.yzx.kiosk.datastore.AppStoreDataSource +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PrinterModule { + + @Provides + @Singleton + fun providePrinterService( + @ApplicationContext context: Context, + appStoreDataSource: AppStoreDataSource, + printStatusManager: PrintStatusManager + ): PrinterService { + return PrinterService(context, appStoreDataSource, printStatusManager) + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt b/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt new file mode 100644 index 0000000..47ce045 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt @@ -0,0 +1,314 @@ +package com.yzx.kiosk.priter + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import com.yzx.kiosk.App +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import jp.co.dnp.photoprintlib.DNPPhotoPrint +import jp.co.dnpLib.BmpUtil +import jp.co.dnpLib.common.AndroidBmpUtil +import jp.co.dnpLib.common.ECutterMode +import jp.co.dnpLib.common.EMediaSize +import jp.co.dnpLib.common.EOverCoat +import jp.co.dnpLib.common.EResolution +import jp.co.dnpLib.print.PrintJob +import jp.co.dnpLib.print.PrintJobStatus +import jp.co.dnpLib.print.PrintManager +import jp.co.dnpLib.print.PrintQueue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Singleton +import kotlin.math.min + +/** + * 打印服务类 + * 负责打印机初始化和打印任务管理 + */ +@Singleton +class PrinterService( + private val context: Context, + private val appStoreDataSource: AppStoreDataSource, + private val printStatusManager: PrintStatusManager +) { + companion object { + private const val TAG = "PrinterService" + } + + private var mDnpPhotoPrint: DNPPhotoPrint? = null + + /** + * 初始化打印机(耗时操作,需在IO线程调用) + * @return 是否初始化成功 + */ + suspend fun initDnpPrint(): Boolean = withContext(Dispatchers.IO) { + try { + mDnpPhotoPrint = App.getDnpPhotoPrint() + + // 定义打印机端口数组 + val printers = Array(10) { IntArray(2) } + LogUtils.d("printer", printers.toString()) + + // 获取打印机端口信息 + val portNum: Int? = mDnpPhotoPrint?.GetPrinterPortNum(printers) + LogUtils.d("printer-portNum", "端口 printers = " + printers.contentDeepToString()) + + // 保存第一个打印机ID + val printId = printers[0]!![0] + appStoreDataSource.savePrintId(printId) + // 初始化成功,设置为在线且空闲状态 + printStatusManager.setOnline(true) + printStatusManager.setIdle() + LogUtils.d(TAG, "打印机初始化成功,打印机ID: $printId") + ToastUtils.show("打印机连接成功") + true + } catch (e: Exception) { + LogUtils.e(TAG, "打印机初始化失败: ${e.message}") + // 初始化失败,设置为离线状态 + printStatusManager.setOnline(false) + ToastUtils.show("打印机连接失败: ${e.message ?: "未知错误"}") + false + } + } + + /** + * 获取当前打印机ID + */ + fun getPrintId(): Int { + return appStoreDataSource.getPrintId() + } + + /** + * 准备打印图片:保持原比例,全内容居中,空白区域背景为白色,宽图可调整方向 + * @param originalBitmap 原始图片(不会被回收) + * @param printSize 打印尺寸 + * @param printId 打印机ID + * @return 处理后的图片 + */ + private fun preparePrintBitmap(originalBitmap: Bitmap, printSize: PRINTSIZE, printId: Int): Bitmap { + var workingBitmap: Bitmap? = null // 用于处理的临时bitmap + var rotatedBitmap: Bitmap? = null // 旋转后的bitmap(如果需要) + var scaledBitmap: Bitmap? = null // 缩放后的bitmap + + try { + val originalWidth = originalBitmap.width + val originalHeight = originalBitmap.height + val isWideImage = originalWidth > originalHeight // 是否为宽图 + + LogUtils.d(TAG, "原始图片: ${originalWidth}x${originalHeight}, 是否宽图: $isWideImage") + + // 判断是否需要旋转 + // RX1打印机需要横向图片(1840x1240),QW410需要纵向图片(1266x1836) + val needRotate = if (printId != PrintManager.EPrinter.QW410_DEF.id) { + // RX1打印机:如果图片是纵向的,需要旋转90度 + !isWideImage + } else { + // QW410打印机:如果图片是横向的,需要旋转90度 + isWideImage + } + + // 如果需要旋转,先旋转图片 + if (needRotate) { + LogUtils.d(TAG, "旋转图片90度以适应打印方向") + val matrix = Matrix() + matrix.postRotate(90f) + rotatedBitmap = Bitmap.createBitmap( + originalBitmap, 0, 0, + originalBitmap.width, originalBitmap.height, matrix, true + ) + workingBitmap = rotatedBitmap + LogUtils.d(TAG, "旋转后图片尺寸: ${workingBitmap.width}x${workingBitmap.height}") + } else { + workingBitmap = originalBitmap + } + + // 计算缩放比例,保持原比例 + val scale = min( + printSize.width.toFloat() / workingBitmap.width, + printSize.height.toFloat() / workingBitmap.height + ) + + val scaledWidth = (workingBitmap.width * scale).toInt() + val scaledHeight = (workingBitmap.height * scale).toInt() + + LogUtils.d(TAG, "缩放比例: $scale, 缩放后尺寸: ${scaledWidth}x${scaledHeight}") + + // 创建缩放后的图片 + scaledBitmap = Bitmap.createScaledBitmap(workingBitmap, scaledWidth, scaledHeight, true) + + // 创建白色背景的画布 + val outputBitmap = Bitmap.createBitmap( + printSize.width, + printSize.height, + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(outputBitmap) + + // 填充白色背景 + canvas.drawColor(Color.WHITE) + + // 计算居中位置 + val left = (printSize.width - scaledWidth) / 2f + val top = (printSize.height - scaledHeight) / 2f + + LogUtils.d(TAG, "居中位置: left=$left, top=$top") + + // 在画布上绘制缩放后的图片(居中) + canvas.drawBitmap(scaledBitmap, left, top, null) + + // 回收临时bitmap + scaledBitmap?.let { if (!it.isRecycled) it.recycle() } + rotatedBitmap?.let { if (!it.isRecycled) it.recycle() } + + return outputBitmap + } catch (e: Exception) { + LogUtils.e(TAG, "准备打印图片失败: ${e.message}") + // 如果出错,回收可能创建的临时bitmap + scaledBitmap?.let { if (!it.isRecycled) it.recycle() } + rotatedBitmap?.let { if (!it.isRecycled) it.recycle() } + throw e + } + } + + /** + * 打印图片(耗时操作) + * @param originalBitmap 要打印的原始图片 + * @param callback 打印结果回调 + */ + suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = withContext(Dispatchers.IO) { + if (originalBitmap == null) { + LogUtils.e(TAG, "获取图片失败") + callback.onPrintResult(false,"获取图片失败") + return@withContext + } + LogUtils.e(TAG, "获取图片成功") + + // 在开始打印前,检查打印机是否在线 + if (printStatusManager.isOffline()) { + LogUtils.e(TAG, "打印机离线,无法打印") + callback.onPrintResult(false, "打印机离线,无法打印") + return@withContext + } + + // 设置打印状态为正在打印 + printStatusManager.setPrinting() + + try { + val printQueue = PrintQueue(context) + val printId = appStoreDataSource.getPrintId() + + // 再次检查打印机ID是否有效(如果打印机未初始化,printId 可能为 0) + if (printId == 0) { + LogUtils.e(TAG, "打印机未初始化,printId=0") + printStatusManager.setOffline() + callback.onPrintResult(false, "打印机未初始化") + return@withContext + } + + LogUtils.d(TAG, "开始打印,打印机ID: $printId") + LogUtils.d(TAG, "原始图片尺寸: ${originalBitmap.width} x ${originalBitmap.height}") + + // 获取打印尺寸 + val printSize = if (printId != PrintManager.EPrinter.QW410_DEF.id) { + PRINTSIZE.RX1 + } else { + PRINTSIZE.QW410 + } + LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}") + + // 准备打印图片(保持原比例,居中,白色背景) + val outputBitmap: Bitmap + val outFile = File( + context.getExternalFilesDir(null), + "ProcessedPhotos/${System.currentTimeMillis()}_print.bmp" + ) + outFile.parentFile?.mkdirs() + + try { + outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId) + + if (printId != PrintManager.EPrinter.QW410_DEF.id) { + BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue) + } else { + AndroidBmpUtil.save( + outputBitmap, + outFile.absolutePath, + PRINTSIZE.QW410.width, + PRINTSIZE.QW410.height + ) + } + } catch (e: Exception) { + e.printStackTrace() + callback.onPrintResult(false,"图片处理失败: ${e.message}") + return@withContext + } + + // 创建打印任务 + val job = PrintJob( + outFile.absolutePath, + 1, + EResolution.RESO300, + if (printId != PrintManager.EPrinter.QW410_DEF.id) EMediaSize.CSP_PC else EMediaSize.CSP_4x6, + ECutterMode.MODE_STANDARD, + EOverCoat.GLOSSY, + PrintJobStatus.PROCESSING, + "打印中", + 1 + ) + printQueue.addJob(job) + + if (printId != PrintManager.EPrinter.QW410_DEF.id) { + // 打印完成,设置为空闲 + printStatusManager.setIdle() + // 回收 outputBitmap + try { + if (!outputBitmap.isRecycled) { + outputBitmap.recycle() + LogUtils.d(TAG, "outputBitmap 已回收") + } + } catch (ex: Exception) { + LogUtils.e(TAG, "回收 outputBitmap 异常: ${ex.message}") + } + callback.onPrintResult(true) + } else { + // QW410打印机需要延迟回调 + delay(18000) + LogUtils.e(TAG, "打印成功回调了-----------------------------") + // 打印完成,设置为空闲 + printStatusManager.setIdle() + // 回收 outputBitmap + try { + if (!outputBitmap.isRecycled) { + outputBitmap.recycle() + LogUtils.d(TAG, "outputBitmap 已回收") + } + } catch (ex: Exception) { + LogUtils.e(TAG, "回收 outputBitmap 异常: ${ex.message}") + } + callback.onPrintResult(true) + } + + } catch (e: Exception) { + LogUtils.e(TAG, "打印失败: ${e.message}") + // 打印失败,设置为空闲 + printStatusManager.setIdle() + callback.onPrintResult(false,"打印失败: ${e.message}") + } + } +} + +/** + * 打印结果回调接口 + */ +interface PrintCallback { + fun onPrintResult(isSuccess: Boolean,msg:String = "") +} + + diff --git a/app/src/main/java/com/yzx/kiosk/receiver/BootReceiver.kt b/app/src/main/java/com/yzx/kiosk/receiver/BootReceiver.kt new file mode 100644 index 0000000..742105d --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/receiver/BootReceiver.kt @@ -0,0 +1,110 @@ +package com.yzx.kiosk.receiver + +import android.app.ActivityManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.yzx.kiosk.SplashActivity +import com.yzx.kiosk.utils.LogUtils + +/** + * 开机自启动广播接收器 + * 接收系统开机完成广播,自动启动应用 + */ +class BootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + try { + val action = intent.action + LogUtils.d("BootReceiver", "收到广播: $action, package: ${context.packageName}") + + // 检查是否是开机完成广播 + // 注意:ACTION_MY_PACKAGE_REPLACED 和 ACTION_PACKAGE_REPLACED 应该由 VersionUpdateManager 处理 + // 这里只处理真正的开机完成广播,避免与应用更新重启冲突 + if (Intent.ACTION_BOOT_COMPLETED == action || + Intent.ACTION_LOCKED_BOOT_COMPLETED == action) { + + LogUtils.d("BootReceiver", "检测到开机完成,准备启动应用") + + // 使用 goAsync() 保持 receiver 活跃,避免在 Handler 执行前被销毁 + val pendingResult = goAsync() + + // 延迟启动,确保系统完全启动 + Handler(Looper.getMainLooper()).postDelayed({ + try { + // 检查应用是否已经在前台运行 + if (isAppInForeground(context)) { + LogUtils.d("BootReceiver", "应用已在前台运行,无需启动") + } else { + startApp(context) + LogUtils.d("BootReceiver", "应用启动完成") + } + } catch (e: Exception) { + LogUtils.e("BootReceiver", "启动应用异常: ${e.message}") + e.printStackTrace() + } finally { + // 完成异步操作 + pendingResult.finish() + } + }, 2000) // 延迟2秒启动,确保系统服务已就绪 + } else if (Intent.ACTION_MY_PACKAGE_REPLACED == action || + (Intent.ACTION_PACKAGE_REPLACED == action && + intent.dataString?.contains(context.packageName) == true)) { + // 应用更新广播,由 VersionUpdateManager 处理,这里只记录日志 + LogUtils.d("BootReceiver", "检测到应用更新广播,但由 VersionUpdateManager 处理,不执行启动") + } else { + LogUtils.d("BootReceiver", "广播不匹配,忽略: $action") + } + } catch (e: Exception) { + LogUtils.e("BootReceiver", "处理开机广播异常: ${e.message}") + e.printStackTrace() + } + } + + /** + * 检查应用是否在前台运行 + */ + private fun isAppInForeground(context: Context): Boolean { + return try { + val appProcessInfo = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(appProcessInfo) + val isForeground = appProcessInfo.importance == + ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + LogUtils.d("BootReceiver", "应用前台状态检查: $isForeground (importance: ${appProcessInfo.importance})") + isForeground + } catch (e: Exception) { + LogUtils.e("BootReceiver", "检查应用前台状态失败: ${e.message}") + e.printStackTrace() + // 如果检查失败,默认返回 false,允许启动应用 + false + } + } + + /** + * 启动应用 + */ + private fun startApp(context: Context) { + try { + val intent = Intent(context, SplashActivity::class.java).apply { + // 添加标志,确保在新任务栈中启动 + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + // 对于 Android 8.0 及以上版本,需要显式设置组件 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setPackage(context.packageName) + } + } + + context.startActivity(intent) + LogUtils.d("BootReceiver", "应用启动成功 - SplashActivity") + } catch (e: Exception) { + LogUtils.e("BootReceiver", "启动应用失败: ${e.message}") + e.printStackTrace() + throw e + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/receiver/BootReceiver2.java b/app/src/main/java/com/yzx/kiosk/receiver/BootReceiver2.java new file mode 100644 index 0000000..5ec9a48 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/receiver/BootReceiver2.java @@ -0,0 +1,26 @@ +package com.yzx.kiosk.receiver; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.yzx.kiosk.SplashActivity; + + +public class BootReceiver2 extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null) return; + String action = intent.getAction(); + Log.e("BootReceiver", "接收到广播:" + action); + if (Intent.ACTION_BOOT_COMPLETED.equals(action) + || Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)) { + Log.e("BootReceiver", "系统启动完成,准备启动主界面"); + Intent startIntent = new Intent(context, SplashActivity.class); + startIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(startIntent); + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/theme/AppTheme.kt b/app/src/main/java/com/yzx/kiosk/theme/AppTheme.kt new file mode 100644 index 0000000..568a6b5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/theme/AppTheme.kt @@ -0,0 +1,37 @@ +package com.yzx.kiosk.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + + MaterialTheme( + colorScheme = LightColorScheme, + content = content + ) +} + +private val LightColorScheme = lightColorScheme( + primary = Primary, + secondary = ColorPurple, + tertiary = ColorSuccess, + background = BackgroundColor, + surface = BgWhiteLight, + error = ColorDanger, + onPrimary = TextWhite, + onSecondary = TextWhite, + onTertiary = TextWhite, + onBackground = TextPrimaryLight, + onSurface = TextPrimaryLight, + onError = TextWhite, + outline = BorderLight, + surfaceVariant = BgContentLight, + surfaceTint = Primary, + surfaceContainer = BgWhiteLight, + surfaceContainerHigh = BgWhiteLight, + surfaceContainerHighest = BgWhiteLight +) \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/theme/Color.kt b/app/src/main/java/com/yzx/kiosk/theme/Color.kt new file mode 100644 index 0000000..230f09b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/theme/Color.kt @@ -0,0 +1,39 @@ +package com.yzx.kiosk.theme + +import androidx.compose.ui.graphics.Color + +val Primary = Color(0xFF0073FF) + +val ColorDanger = Color(0xFFFF2B2B) + +val ColorPurple = Color(0xFF6831FF) + +val ColorSuccess = Color(0xFF09BE4F) + +val TextPrimaryLight = Color(0xFF181818) + +val TextWhite = Color(0xFFFFFFFF) + +val BgGreyLight = Color(0xFFF9FAFB) + +val BgWhiteLight = Color(0xFFFFFFFF) + +val BgContentLight = Color(0xFFF8F8F8) + +val BorderLight = Color(0xFFEEEEEE) + +val TextColor333 = Color(0xFF333333) + +val TextColor666 = Color(0xFF666666) + +val TextColor563 = Color(0xFF4B5563) + +val TextColorBlack= Color(0xFF2E3746) + +val TextColor999 = Color(0xFF999999) + +val ColorTabGray = Color(0xFF7B8EAA) + +val BackgroundColor = Color(0xFFF4F4F4) + +val LocationgroundColor = Color(0xFFB2D5FF) diff --git a/app/src/main/java/com/yzx/kiosk/theme/Size.kt b/app/src/main/java/com/yzx/kiosk/theme/Size.kt new file mode 100644 index 0000000..5cd7ca5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/theme/Size.kt @@ -0,0 +1,5 @@ +package com.yzx.kiosk.theme + +import androidx.compose.ui.unit.dp + +val TITLE_BAR_HEIGHT = 96.dp diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/DatePickDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/DatePickDialog.kt new file mode 100644 index 0000000..a77a8d9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/DatePickDialog.kt @@ -0,0 +1,76 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.commandiron.wheel_picker_compose.WheelDatePicker +import com.yzx.kiosk.component.button.AppButton +import java.time.LocalDate + +@Composable +fun DatePickDialog( + onConfirm: (LocalDate) -> Unit = { _ -> }, + onDismiss: () -> Unit = {} +) { + + var snappedDate: LocalDate = LocalDate.now() + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = true + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(10.dp)) + .padding(horizontal = 15.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + WheelDatePicker( + startDate = LocalDate.now(), + ) { date -> + snappedDate = date + } + + Spacer(modifier = Modifier.height(24.dp)) + + AppButton( + modifier = Modifier + .fillMaxWidth() + .height(45.dp), + text = "确认", + textSize = 17.sp, + fontWeight = FontWeight.Medium, + onClick = { + onConfirm(snappedDate) + onDismiss() + }, + enable = true, + ) + } + } +} + +@Preview +@Composable +fun DatePickDialogPreview() { + DatePickDialog( + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PaperLowWarningDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PaperLowWarningDialog.kt new file mode 100644 index 0000000..e92ddab --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PaperLowWarningDialog.kt @@ -0,0 +1,105 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog + +/** 剩余张数 ≤ 该值(含)时提示补充相纸 */ +object PrintPaperWarning { + const val LOW_SHEETS_THRESHOLD: Int = 20 +} + +fun shouldWarnLowPrintPaper(remainingSheets: Int): Boolean = + remainingSheets <= PrintPaperWarning.LOW_SHEETS_THRESHOLD + +/** + * 相纸余量不足温馨提示(与首页客服电话同源展示号码) + */ +@Composable +fun PaperLowWarningDialog( + remainingSheets: Int, + servicePhone: String, + onDismiss: () -> Unit +) { + val phoneDisplay = servicePhone.ifBlank { "--" } + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "温馨提示", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF000000), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = buildAnnotatedString { + append("当前打印相纸剩余 ") + withStyle(SpanStyle(color = Color(0xFFE53935), fontWeight = FontWeight.Medium)) { + append(remainingSheets.toString()) + } + append(" 张,建议酌情打印。请联系工作人员及时补充相纸,联系电话:") + append(phoneDisplay) + append("。") + }, + fontSize = 16.sp, + color = Color(0xFF333333), + lineHeight = 24.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(28.dp)) + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFE8F4FC), + contentColor = Color(0xFF2196F3) + ), + shape = RoundedCornerShape(12.dp), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Text( + text = "我知道了", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PaperNumInputDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PaperNumInputDialog.kt new file mode 100644 index 0000000..8ebdeae --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PaperNumInputDialog.kt @@ -0,0 +1,153 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.yzx.kiosk.theme.TextColor333 + +@Composable +fun PaperNumInputDialog( + initialValue: Int, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit, + title: String = "设置打印纸张数" +) { + var paperNum by remember(initialValue) { mutableStateOf(initialValue.toString()) } + var errorMessage by remember { mutableStateOf(null) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 标题 + Text( + text = title, + fontSize = 18.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + color = TextColor333, + modifier = Modifier.padding(bottom = 20.dp) + ) + + // 纸张数输入框 + Box( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp)) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = paperNum, + onValueChange = { newValue -> + // 只允许数字输入 + if (newValue.isEmpty() || (newValue.all { it.isDigit() } && newValue.toIntOrNull() != null && newValue.toInt() >= 0)) { + paperNum = newValue + errorMessage = null + } + }, + textStyle = TextStyle( + fontSize = 16.sp, + color = TextColor333, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + // 错误提示 + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = MaterialTheme.colorScheme.error, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 按钮 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 取消按钮 + Button( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(44.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFF5F5F5) + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "取消", + color = TextColor333, + fontSize = 16.sp + ) + } + + // 确认按钮 + Button( + onClick = { + val num = paperNum.toIntOrNull() + if (num == null || num < 0) { + errorMessage = "请输入有效的纸张数" + } else { + onConfirm(num) + } + }, + modifier = Modifier + .weight(1f) + .height(44.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "确认", + color = Color.White, + fontSize = 16.sp + ) + } + } + } + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PasswordInputDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PasswordInputDialog.kt new file mode 100644 index 0000000..a10a796 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/PasswordInputDialog.kt @@ -0,0 +1,151 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.yzx.kiosk.theme.TextColor333 + +@Composable +fun PasswordInputDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, + title: String = "请输入密码" +) { + var password by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 标题 + Text( + text = title, + fontSize = 18.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + color = TextColor333, + modifier = Modifier.padding(bottom = 20.dp) + ) + + // 密码输入框 + Box( + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + .background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp)) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = password, + onValueChange = { newValue -> + // 只允许数字输入 + if (newValue.all { it.isDigit() }) { + password = newValue + errorMessage = null + } + }, + textStyle = TextStyle( + fontSize = 16.sp, + color = TextColor333, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + // 错误提示 + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = MaterialTheme.colorScheme.error, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 按钮 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 取消按钮 + Button( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(44.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFF5F5F5) + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "取消", + color = TextColor333, + fontSize = 16.sp + ) + } + + // 确认按钮 + Button( + onClick = { + if (password.isEmpty()) { + errorMessage = "请输入密码" + } else { + onConfirm(password) + } + }, + modifier = Modifier + .weight(1f) + .height(44.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "确认", + color = Color.White, + fontSize = 16.sp + ) + } + } + } + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/ProgressLoadingView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/ProgressLoadingView.kt new file mode 100644 index 0000000..6cedace --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/ProgressLoadingView.kt @@ -0,0 +1,77 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.component.loading.LottieLoading +import com.yzx.kiosk.theme.TextColor666 + +@Composable +fun ProgressLoadingView( + progress: Int, + isLoading: Boolean = false, + content: @Composable () -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + content() + + if (isLoading) { + BackHandler(enabled = true) { + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.2f)) + .clickable( + enabled = true, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) {}, + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .width(100.dp) + .clip(RoundedCornerShape(15.dp)) + .background(Color.White) + .padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LottieLoading() + + Text( + text = "上传中${progress}%", + fontSize = 12.sp, + color = TextColor666, + ) + } + } + } + } +} + +@Preview +@Composable +fun ProgressLoadingViewPreview() { + ProgressLoadingView(progress = 40, isLoading = true) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/RefreshQrCodeDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/RefreshQrCodeDialog.kt new file mode 100644 index 0000000..1bf8212 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/RefreshQrCodeDialog.kt @@ -0,0 +1,100 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.yzx.kiosk.theme.TextColor333 + +@Composable +fun RefreshQrCodeDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + title: String = "是否刷新", + message: String = "二维码将在3秒内刷新" +) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(36.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 标题 + Text( + text = title, + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.padding(bottom = 52.dp) + ) + + // 消息 + Text( + text = message, + fontSize = 24.sp, + color = Color(0xFF7B8EAA), + modifier = Modifier.padding(bottom = 52.dp) + ) + + // 按钮 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(36.dp) + ) { + // 取消返回按钮 + Button( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(72.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFEFF6FF) + ), + shape = RoundedCornerShape(15.dp) + ) { + Text( + text = "取消返回", + color = Color(0xFF0073FF), // 蓝色文字 + fontSize = 24.sp + ) + } + + // 立即刷新按钮 + Button( + onClick = onConfirm, + modifier = Modifier + .weight(1f) + .height(72.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF0073FF) // 蓝色背景 + ), + shape = RoundedCornerShape(15.dp) + ) { + Text( + text = "立即刷新", + color = Color.White, // 白色文字 + fontSize = 24.sp + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/TextAreaInputDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/TextAreaInputDialog.kt new file mode 100644 index 0000000..ae594db --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/TextAreaInputDialog.kt @@ -0,0 +1,85 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.yzx.kiosk.component.button.AppButton +import com.yzx.kiosk.ui.common.view.TextAreaBorderView + +@Composable +fun TextAreaInputDialog( + lastText: String = "", + maxLength: Int = Int.MAX_VALUE, + onConfirm: (String) -> Unit = { _ -> }, + onDismiss: () -> Unit = {} +) { + + var text by remember { mutableStateOf(lastText) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = true + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(10.dp)) + .padding(horizontal = 15.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + TextAreaBorderView( + text = text, + nextAction = ImeAction.Done, + fontSize = 14.sp, + maxLength = maxLength, + onTextChange = { text = it }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + AppButton( + modifier = Modifier + .fillMaxWidth() + .height(45.dp), + text = "确认", + textSize = 17.sp, + fontWeight = FontWeight.Medium, + onClick = { + onConfirm(text) + onDismiss() + }, + enable = text.isNotEmpty(), + ) + } + } +} + +@Preview +@Composable +fun TextAreaInputDialogPreview() { + TextAreaInputDialog( + lastText = "这是lastText" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/TimePickDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/TimePickDialog.kt new file mode 100644 index 0000000..f5d2de6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/TimePickDialog.kt @@ -0,0 +1,77 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.commandiron.wheel_picker_compose.WheelTimePicker +import com.yzx.kiosk.component.button.AppButton +import java.time.LocalTime + +@Composable +fun TimePickDialog( + onConfirm: (LocalTime) -> Unit = { _ -> }, + onDismiss: () -> Unit = {} +) { + + var snappedTime: LocalTime = LocalTime.of(0, 0) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = true + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White, RoundedCornerShape(10.dp)) + .padding(horizontal = 15.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + WheelTimePicker( + startTime = LocalTime.of(0, 0), + ) { time -> + snappedTime = time + } + + Spacer(modifier = Modifier.height(24.dp)) + + AppButton( + modifier = Modifier + .fillMaxWidth() + .height(45.dp), + text = "确认", + textSize = 17.sp, + fontWeight = FontWeight.Medium, + onClick = { + //snappedTime 转 HH:mm + onConfirm(snappedTime) + onDismiss() + }, + enable = true, + ) + } + } +} + +@Preview +@Composable +fun TimePickDialogPreview() { + TimePickDialog( + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/dialog/UpdateDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/UpdateDialog.kt new file mode 100644 index 0000000..d0acb93 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/dialog/UpdateDialog.kt @@ -0,0 +1,193 @@ +package com.yzx.kiosk.ui.common.dialog + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.yzx.kiosk.network.model.response.VersionResponse + +/** + * 版本更新对话框 + */ +@Composable +fun UpdateDialog( + versionResponse: VersionResponse, + currentVersionName: String, + isForceUpdate: Boolean, + downloadProgress: Int, + isDownloading: Boolean, + onDismiss: () -> Unit, + onUpdate: () -> Unit +) { + Dialog( + onDismissRequest = { + // 强制更新时不允许关闭 + if (!isForceUpdate) { + onDismiss() + } + }, + properties = DialogProperties( + dismissOnBackPress = !isForceUpdate, + dismissOnClickOutside = !isForceUpdate, + usePlatformDefaultWidth = false + ) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.85f) + .padding(16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 标题(下载时显示下载状态) + Text( + text = if (isDownloading) "正在下载更新..." else "版本更新", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 当前版本 + Text( + text = "当前版本:$currentVersionName", + fontSize = 16.sp, + color = Color(0xFF666666), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 目标版本 + Text( + text = "最新版本:${versionResponse.version}", + fontSize = 16.sp, + color = Color(0xFF666666), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 更新内容 + Text( + text = "更新内容:", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 更新内容描述(可滚动) + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .background( + color = Color(0xFFF5F5F5), + shape = RoundedCornerShape(8.dp) + ) + ) { + Text( + text = versionResponse.description.ifEmpty { "暂无更新内容" }, + fontSize = 14.sp, + color = Color(0xFF666666), + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .verticalScroll(rememberScrollState()), + lineHeight = 20.sp + ) + } + + // 下载进度(如果正在下载) + if (isDownloading) { + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { downloadProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = Color(0xFF2196F3), + trackColor = Color(0xFFE0E0E0) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "下载进度:$downloadProgress%", + fontSize = 14.sp, + color = Color(0xFF666666) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 按钮 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 强制更新时只显示"立即更新"按钮 + if (!isForceUpdate) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color(0xFF666666) + ) + ) { + Text( + text = "暂不更新", + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + + Button( + onClick = onUpdate, + modifier = Modifier + .weight(1f) + .height(48.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3) + ), + enabled = !isDownloading + ) { + Text( + text = if (isDownloading) "下载中..." else "立即更新", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.White + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/AgreementRow.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/AgreementRow.kt new file mode 100644 index 0000000..cbe3cd8 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/AgreementRow.kt @@ -0,0 +1,162 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.R + +/** + * 协议勾选组件:⭕ 已阅读并同意 《用户协议》 与 《隐私政策》 + * 点击《用户协议》/《隐私政策》由外部回调处理:goUserAgreement / goPrivacyPolicy + */ +@Composable +fun AgreementRow( + checked: Boolean, + onCheckedChange: () -> Unit, + tosLabel: String = "用户协议", + privacyLabel: String = "隐私政策", + tint: Color = Color(0xFF208BFF), + modifier: Modifier = Modifier, + goUserAgreement: () -> Unit, + goPrivacyPolicy: () -> Unit, +) { + val annotated = AgreementTextAnnotatedString( + tint = tint, + tosLabel = tosLabel, + privacyLabel = privacyLabel, + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + // 左侧勾选图标(使用你的资源) + IconButton( + onClick = onCheckedChange, + modifier = Modifier.size(18.dp), + ) { + Image( + painter = painterResource( + if (checked) R.mipmap.icon_cb_privacy_selected + else R.mipmap.icon_cb_privacy_unselect + ), + contentDescription = "check", + modifier = Modifier.size(18.dp) + ) + } + + Spacer(Modifier.width(8.dp)) + + // 右侧富文本:点击交由外部处理 + LinkText( + text = annotated, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp), + maxLines = 2 + ) { tag -> + when (tag) { + "tos" -> goUserAgreement() + "privacy" -> goPrivacyPolicy() + } + } + } +} + +/** + * 基于 StringAnnotation 的可点击富文本。 + * onTagClick 接收注解 tag("tos"/"privacy")。 + */ +@Composable +fun LinkText( + text: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + onTagClick: (tag: String) -> Unit +) { + var layoutResult: TextLayoutResult? by remember { mutableStateOf(null) } + + BasicText( + text = text, + style = style, + maxLines = maxLines, + overflow = overflow, + onTextLayout = { layoutResult = it }, + modifier = modifier.pointerInput(text, layoutResult) { + detectTapGestures { pos -> + val layout = layoutResult ?: return@detectTapGestures + val offset = layout.getOffsetForPosition(pos) + text.getStringAnnotations(start = offset, end = offset) + .firstOrNull() + ?.let { ann -> onTagClick(ann.tag) } + } + } + ) +} + +/** + * 富文本内容:已阅读并同意 《用户协议》 与 《隐私政策》 + * 使用 pushStringAnnotation 标注点击范围("tos"/"privacy") + */ +@Composable +fun AgreementTextAnnotatedString( + tint: Color, + tosLabel: String, + privacyLabel: String, +): AnnotatedString = buildAnnotatedString { + val gray = Color(0xFF666666) + + withStyle(SpanStyle(color = gray, fontSize = 12.sp)) { + append("已阅读并同意") + } + + // 《用户协议》 + pushStringAnnotation(tag = "tos", annotation = "tos") + withStyle( + SpanStyle( + color = tint, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + ) { append("《$tosLabel》") } + pop() // 结束 tos 区段 + + withStyle(SpanStyle(color = gray, fontSize = 12.sp)) { append("与") } + + // 《隐私政策》 + pushStringAnnotation(tag = "privacy", annotation = "privacy") + withStyle( + SpanStyle( + color = tint, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + ) { append("《$privacyLabel》") } + pop() // 结束 privacy 区段 +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/AvatarView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/AvatarView.kt new file mode 100644 index 0000000..8ab23a5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/AvatarView.kt @@ -0,0 +1,130 @@ +package com.yzx.kiosk.ui.common.view + +import android.app.Activity +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.yalantis.ucrop.UCrop +import com.yalantis.ucrop.UCrop.Options +import com.yzx.kiosk.R +import com.yzx.kiosk.ext.debounceClickable +import com.yzx.kiosk.theme.Primary +import com.yzx.kiosk.ui.crop.MyUCropActivity +import java.io.File + + +@Composable +fun AvatarView( + context: Context, + avatarUrl: String = "", + uploadAvatarFile: (Uri?) -> Unit = {}, + enabled: Boolean = true, + showChangeText: Boolean = false, +) { + + val uCropLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + val croppedUri = UCrop.getOutput(result.data!!) + if (croppedUri != null) { + uploadAvatarFile(croppedUri) + } + } + } + + fun startUCrop(context: Context, sourceUri: Uri) { + val destUri = + Uri.fromFile(File(context.cacheDir, "cropped_${System.currentTimeMillis()}.jpg")) + + val options = Options() + options.setStatusBarColor(android.graphics.Color.BLACK) + + val intent = UCrop.of(sourceUri, destUri) + .withAspectRatio(1f, 1f) + .withMaxResultSize(480, 480) + .withOptions(options) + .getIntent(context) + .setClass(context, MyUCropActivity::class.java) + uCropLauncher.launch(intent) + } + + val pickPhotoLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { + startUCrop(context, uri) + } + } + + val clickModifier = if (enabled) { + Modifier.debounceClickable(key = "avatar", onClick = { + pickPhotoLauncher.launch("image/*") + }) + } else { + Modifier + } + + Box( + modifier = Modifier + .size(72.dp) + .then(clickModifier), + contentAlignment = Alignment.BottomEnd, + ) { + if (avatarUrl.isEmpty()) { + Icon( + painter = painterResource(id = R.drawable.ic_user_info_avatar_default), + contentDescription = null, + modifier = Modifier + .size(72.dp) + .then(if (enabled) clickModifier else Modifier), + tint = Primary, + ) + } else { + AsyncImage( + model = avatarUrl, + contentDescription = null, + modifier = Modifier + .size(72.dp) + .clip(CircleShape), + ) +// Icon( +// painter = painterResource(id = R.drawable.ic_user_info_avatar_add), +// contentDescription = null, +// tint = Primary, +// ) + } + + // 显示"点击更换"文字 + if (showChangeText && enabled) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = "点击更换", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/CodeInputField.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/CodeInputField.kt new file mode 100644 index 0000000..37a5c51 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/CodeInputField.kt @@ -0,0 +1,77 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.theme.TextColor333 + +@Composable +fun CodeInputField( + code: String, + onCodeChange: (String) -> Unit, + codeFieldFocused: MutableState, + onRequestCode: () -> Unit, + isCountingDown: Boolean, // 新增:倒计时中 + countdown: Int, // 新增:剩余秒数 + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .height(46.dp) + .background(Color(0xFFF4F4F4), RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = code, + onValueChange = { + if (it.length <= 6 && it.all(Char::isDigit)) onCodeChange(it) + }, + textStyle = TextStyle(fontSize = 14.sp, color = TextColor333), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + singleLine = true, + modifier = Modifier + .weight(1f) + .onFocusChanged { codeFieldFocused.value = it.isFocused } + ) { inner -> + Box(contentAlignment = Alignment.CenterStart) { + if (code.isEmpty()) { + Text("请输入验证码", color = Color.Gray, fontSize = 14.sp) + } + inner() + } + } + + Text( + text = if (isCountingDown) "重新获取(${countdown}s)" else "获取验证码", + color = if (isCountingDown) Color.Gray else Color(0xFF007BFF), + fontSize = 14.sp, + modifier = Modifier + .padding(start = 8.dp) + .clickable(enabled = !isCountingDown) { + if (!isCountingDown) onRequestCode() + } + ) + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/EmptyStateView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/EmptyStateView.kt new file mode 100644 index 0000000..13b444d --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/EmptyStateView.kt @@ -0,0 +1,61 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.theme.Primary + +@Composable +fun EmptyStateView( + modifier: Modifier = Modifier, + @DrawableRes imageRes: Int? = null, + @StringRes textRes: Int, + buttonText: String? = null, + onButtonClick: (() -> Unit)? = null +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (imageRes != null) { + Image( + painter = painterResource(id = imageRes), + contentDescription = null, + modifier = Modifier.size(160.dp) + ) + } + Text( + text = stringResource(id = textRes), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + fontSize = 14.sp + ) + if (buttonText != null && onButtonClick != null) { + Button( + onClick = onButtonClick, + colors = ButtonDefaults.buttonColors(containerColor = Primary), + ) { + Text(text = buttonText, color = MaterialTheme.colorScheme.onPrimary) + } + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/FocusableDivider.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/FocusableDivider.kt new file mode 100644 index 0000000..b69c8bd --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/FocusableDivider.kt @@ -0,0 +1,37 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.yzx.kiosk.theme.Primary + +/** + * 可聚焦的分隔线组件,在输入框获取焦点时显示主题色,否则显示普通颜色 + * + * @param focusState 聚焦状态,true表示已聚焦 + * @param modifier 可选修饰符 + * @param thickness 线条粗细,默认为1dp + * @param withSpacing 是否在分隔线上方添加间距,默认为true + */ +@Composable +fun FocusableDivider( + focusState: MutableState, + modifier: Modifier = Modifier, + thickness: Float = 1f, + withSpacing: Boolean = true +) { + if (withSpacing) { + Spacer(modifier = Modifier.height(15.dp)) + } + + HorizontalDivider( + modifier = modifier, + thickness = thickness.dp, + color = if (focusState.value) Primary else MaterialTheme.colorScheme.outline + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/MiddleEllipsisTextView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/MiddleEllipsisTextView.kt new file mode 100644 index 0000000..9849ad5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/MiddleEllipsisTextView.kt @@ -0,0 +1,30 @@ +package com.yzx.kiosk.ui.common.view + +import android.text.TextUtils +import android.widget.TextView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun MiddleEllipsisTextView( + text: String, + modifier: Modifier = Modifier, + textSizeSp: Float = 15f, + maxLines: Int = 1 +) { + AndroidView( + modifier = modifier, + factory = { context -> + TextView(context).apply { + this.maxLines = maxLines + this.ellipsize = TextUtils.TruncateAt.MIDDLE + this.textSize = textSizeSp + this.setTextColor(0xFF333333.toInt()) // 可根据需求设置颜色 + } + }, + update = { tv -> + tv.text = text + } + ) +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/OptionPickerBottomSheet.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/OptionPickerBottomSheet.kt new file mode 100644 index 0000000..a582487 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/OptionPickerBottomSheet.kt @@ -0,0 +1,57 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OptionPickerBottomSheet( + title: String, + options: List, + onSelect: (String) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column(Modifier.padding(16.dp)) { + Text(text = title, fontSize = 16.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.height(12.dp)) + androidx.compose.foundation.lazy.LazyColumn { + items(options.size) { idx -> + val item = options[idx] + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onSelect(item) + } + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = item, fontSize = 14.sp, color = Color(0xFF333333)) + } + } + } + Spacer(Modifier.height(12.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/PasswordInputBorderView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/PasswordInputBorderView.kt new file mode 100644 index 0000000..2a7a20b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/PasswordInputBorderView.kt @@ -0,0 +1,116 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.R +import com.yzx.kiosk.theme.TextColor333 +import kotlin.text.ifEmpty + + +@Composable +fun PasswordInputBorderView( + password: String = "", + onPasswordChange: (String) -> Unit = { _ -> {} }, + passwordFieldFocused: MutableState? = null, + placeholder: String = "", + nextAction: ImeAction = ImeAction.Done, +) { + + val placeholderVisible = password.isEmpty() + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(45.dp) + .border(border = BorderStroke(1.dp, Color.LightGray), shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = password, + onValueChange = onPasswordChange, + textStyle = TextStyle( + fontSize = 16.sp, + color = TextColor333 + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = nextAction + ), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { passwordFieldFocused?.let { it1 -> it1.value = it.isFocused } } + ) { innerTextField -> + Box(modifier = Modifier.fillMaxWidth()) { + if (placeholderVisible) { + Text( + text = placeholder.ifEmpty { stringResource(id = R.string.password_hint) }, + color = Color.Gray, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + innerTextField() + } + } + + // 添加显示/隐藏密码的图标按钮 + IconButton( + onClick = { passwordVisible = !passwordVisible }, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(24.dp) + ) { + Image( + painter = painterResource( + id = if (passwordVisible) R.drawable.ic_visible else R.drawable.ic_invisible + ), + contentDescription = if (passwordVisible) + stringResource(id = R.string.hide_password) else stringResource(id = R.string.show_password), + modifier = Modifier.size(20.dp) + ) + } + } +} + +@Preview +@Composable +fun PasswordInputBorderViewPreview() { + PasswordInputBorderView() +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/PasswordInputField.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/PasswordInputField.kt new file mode 100644 index 0000000..796f716 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/PasswordInputField.kt @@ -0,0 +1,115 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.R +import com.yzx.kiosk.theme.TextColor333 + +/** + * 密码输入组件,包含可切换密码显示/隐藏的按钮 + * + * @param password 密码文本 + * @param onPasswordChange 密码变更回调 + * @param passwordFieldFocused 输入框焦点状态 + * @param placeholder 提示文本,默认为"请输入密码" + * @param nextAction 输入法下一步动作,默认为Done + * @param modifier 可选修饰符 + */ +@Composable +fun PasswordInputField( + password: String, + onPasswordChange: (String) -> Unit, + passwordFieldFocused: MutableState, + placeholder: String = "", + nextAction: ImeAction = ImeAction.Done, + modifier: Modifier = Modifier +) { + val placeholderVisible = password.isEmpty() + var passwordVisible by rememberSaveable { mutableStateOf(false) } + + Box( + modifier = modifier + .height(46.dp) + .background(Color(0xFFF4F4F4), RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp), + ) { + BasicTextField( + value = password, + onValueChange = onPasswordChange, + textStyle = TextStyle( + fontSize = 14.sp, + color = TextColor333 + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = nextAction + ), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { passwordFieldFocused.value = it.isFocused } + ) { innerTextField -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart) { + if (placeholderVisible) { + Text( + text = placeholder.ifEmpty { stringResource(id = R.string.password_hint) }, + color = Color.Gray, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + innerTextField() + } + } + + // 添加显示/隐藏密码的图标按钮 + IconButton( + onClick = { passwordVisible = !passwordVisible }, + modifier = Modifier + .align(Alignment.CenterEnd) + .size(30.dp) + ) { + Image( + painter = painterResource( + id = if (passwordVisible) R.mipmap.icon_pwd_visible else R.mipmap.icon_pwd_invisible + ), + contentDescription = if (passwordVisible) + stringResource(id = R.string.hide_password) else stringResource(id = R.string.show_password), + modifier = Modifier.size(22.dp) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/PhoneInputField.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/PhoneInputField.kt new file mode 100644 index 0000000..1b63df2 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/PhoneInputField.kt @@ -0,0 +1,87 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.R +import com.yzx.kiosk.theme.TextColor333 + +/** + * 手机号输入组件,包含国家代码与输入框 + * + * @param phone 手机号文本 + * @param onPhoneChange 手机号变更回调 + * @param phoneFieldFocused 输入框焦点状态 + * @param placeholder 提示文本,默认为"请输入手机号" + * @param nextAction 输入法下一步动作,默认为Next + * @param modifier 可选修饰符 + */ +@Composable +fun PhoneInputField( + phone: String, + onPhoneChange: (String) -> Unit, + phoneFieldFocused: MutableState, + placeholder: String = "", + nextAction: ImeAction = ImeAction.Next, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Phone, + enabled: Boolean = true, +) { + // 手机号输入框及底部线条 + Row( + modifier = modifier + .height(46.dp) + .background(Color(0xFFF4F4F4), RoundedCornerShape(12.dp)) + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + BasicTextField( + value = phone, + onValueChange = onPhoneChange, + textStyle = TextStyle( + fontSize = 14.sp, + color = TextColor333 + ), + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = nextAction + ), + singleLine = true, + enabled = enabled, + modifier = Modifier + .weight(1f) + .onFocusChanged { phoneFieldFocused.value = it.isFocused } + ) { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (phone.isEmpty()) { + Text( + text = placeholder.ifEmpty { stringResource(id = R.string.phone_hint) }, + color = Color.Gray, + fontSize = 14.sp + ) + } + innerTextField() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/SystemView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/SystemView.kt new file mode 100644 index 0000000..ac6031d --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/SystemView.kt @@ -0,0 +1,111 @@ +package com.yzx.kiosk.ui.common.view + +import android.app.Activity +import android.content.pm.ActivityInfo +import android.os.Build +import android.view.WindowInsetsController +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat + +/** + * 沉浸式状态栏 - 状态栏和导航栏可见但透明,内容延伸到系统栏下方 + * + * @param darkIcons 状态栏图标是否为深色(true=深色图标,false=浅色图标) + * @param statusBarColor 状态栏背景颜色,默认为透明 + * @param navigationBarColor 导航栏背景颜色,默认为透明 + */ +@Composable +fun ImmersiveStatusBar( + darkIcons: Boolean = true, + statusBarColor: Int = android.graphics.Color.TRANSPARENT, + navigationBarColor: Int = android.graphics.Color.TRANSPARENT +) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val insetsController = WindowCompat.getInsetsController(window, view) + + // 显示状态栏和导航栏(不隐藏) + insetsController.show(WindowInsetsCompat.Type.statusBars()) + insetsController.show(WindowInsetsCompat.Type.navigationBars()) + + // 设置状态栏图标颜色 + insetsController.isAppearanceLightStatusBars = darkIcons + insetsController.isAppearanceLightNavigationBars = darkIcons + + // 设置状态栏和导航栏为透明或指定颜色 + window.statusBarColor = statusBarColor + window.navigationBarColor = navigationBarColor + } + } +} + +/** + * 全屏模式 - 隐藏状态栏和导航栏 + */ +@Composable +fun FullScreenMode() { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val insetsController = WindowCompat.getInsetsController(window, view) + + // 隐藏状态栏和导航栏 + insetsController.hide(WindowInsetsCompat.Type.statusBars()) + insetsController.hide(WindowInsetsCompat.Type.navigationBars()) + + // 设置系统栏行为:全屏时也隐藏(仅 Android 11+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + @Suppress("DEPRECATION") + window.insetsController?.systemBarsBehavior = + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + // 设置状态栏和导航栏为透明 + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT + } + } +} + +@Composable +fun TransparentStatusBar(darkIcons: Boolean = true, isShowStatusBar: Boolean = true,statusBarColor: Int? = null) { + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = darkIcons + if (!isShowStatusBar) { + insetsController.hide(WindowInsetsCompat.Type.statusBars()) + } + // 设置状态栏颜色 + if (statusBarColor != null) { + window.statusBarColor = statusBarColor + } else { + // 如果未指定颜色,保持透明(Edge-to-Edge 模式) + window.statusBarColor = android.graphics.Color.TRANSPARENT + } + } + } +} + +@Composable +fun LockScreenOrientation(isVerticalOrientation: Boolean = true) { + val view = LocalView.current + DisposableEffect(isVerticalOrientation) { + val activity = view.context as Activity + val originalOrientation = activity.requestedOrientation + activity.requestedOrientation = + if (isVerticalOrientation) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + onDispose { + activity.requestedOrientation = originalOrientation + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/TextAreaBorderView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/TextAreaBorderView.kt new file mode 100644 index 0000000..ab28716 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/TextAreaBorderView.kt @@ -0,0 +1,89 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.theme.TextColor333 + +@Composable +fun TextAreaBorderView( + text: String = "", + onTextChange: (String) -> Unit = { _ -> {} }, + textFieldFocused: MutableState? = null, + placeholder: String = "", + height: Dp = 100.dp, + nextAction: ImeAction = ImeAction.Next, + keyboardType: KeyboardType = KeyboardType.Text, + fontSize: TextUnit = 15.sp, + maxLength: Int = Int.MAX_VALUE, +) { + // 1)把 TextStyle 的 lineHeight 显式设成 fontSize + val fieldTextStyle = TextStyle( + fontSize = fontSize, + lineHeight = fontSize, // ← 关键 + color = TextColor333 + ) + val placeholderStyle = TextStyle( + fontSize = fontSize, + lineHeight = fontSize, // ← 关键 + color = Color.Gray + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .border(BorderStroke(1.dp, Color.LightGray), RoundedCornerShape(8.dp)) + .padding(8.dp), + contentAlignment = Alignment.TopStart // 顶部开始对齐 + ) { + BasicTextField( + value = text, + onValueChange = { if (it.length <= maxLength) onTextChange(it) }, + textStyle = fieldTextStyle, // 用上面带 lineHeight 的样式 + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = nextAction + ), + singleLine = false, + modifier = Modifier + .fillMaxSize() + .onFocusChanged { textFieldFocused?.value = it.isFocused }, + decorationBox = { innerTextField -> + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.TopStart + ) { + if (text.isEmpty()) { + Text( + text = placeholder, + style = placeholderStyle + ) + } + innerTextField() + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/TextInputBorderView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/TextInputBorderView.kt new file mode 100644 index 0000000..fbe5ebe --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/TextInputBorderView.kt @@ -0,0 +1,150 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.theme.TextColor333 + +@Composable +fun TextInputBorderView( + text: String = "", + onTextChange: (String) -> Unit = { _ -> {} }, + textFieldFocused: MutableState? = null, + placeholder: String = "", + nextAction: ImeAction = ImeAction.Next, + keyboardType: KeyboardType = KeyboardType.Text, + fontSize: TextUnit = 15.sp, + maxLength: Int = Int.MAX_VALUE, + isMoneyInput: Boolean = false, // New parameter to enable money validation + isShowEndIcon: Boolean = false, + @DrawableRes drawabId: Int = 0, + iconClick:() -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(46.dp) + .border(border = BorderStroke(1.dp, Color.LightGray), shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + BasicTextField( + value = text, + onValueChange = { newText -> + if (newText.isEmpty()) { + onTextChange(newText) + return@BasicTextField + } + + if (isMoneyInput) { + // Money validation logic + val pattern = Regex("^\\d*\\.?\\d{0,2}$") + if (newText.matches(pattern)) { + val processedText = when { + newText.startsWith(".") -> "0$newText" + newText.startsWith("0") && newText.length > 1 && !newText.startsWith( + "0." + ) -> { + if (newText[1] != '.') newText.substring(1) else newText + } + + else -> newText + } + + if (processedText.length <= maxLength) { + onTextChange(processedText) + } + } else if (newText.length < text.length) { + // Allow deletion + onTextChange(newText) + } + } else { + // Default behavior without money validation + if (newText.length <= maxLength) { + onTextChange(newText) + } + } + }, + textStyle = TextStyle( + fontSize = fontSize, + color = TextColor333 + ), + keyboardOptions = KeyboardOptions( + keyboardType = if (isMoneyInput) KeyboardType.Decimal else keyboardType, + imeAction = nextAction + ), + singleLine = true, + modifier = Modifier + .fillMaxSize() + .weight(1f) + .onFocusChanged { textFieldFocused?.let { it1 -> it1.value = it.isFocused } } + ) { innerTextField -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.CenterStart + ) { + if (text.isEmpty()) { + Text( + text = placeholder, + color = Color.Gray, + fontSize = fontSize, + ) + } + innerTextField() + } + } + + if (isShowEndIcon) { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(drawabId), + contentDescription = "icon", + modifier = Modifier.size(20.dp).clickable { iconClick }, + tint = Color.Unspecified + ) + } + } + } + } +} + +@Preview +@Composable +fun TextInputBorderViewPreview() { + TextInputBorderView() +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/TextInputWithTitleBorderView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/TextInputWithTitleBorderView.kt new file mode 100644 index 0000000..961ae69 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/TextInputWithTitleBorderView.kt @@ -0,0 +1,69 @@ +package com.yzx.kiosk.ui.common.view + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun TextInputWithTitleBorderView( + title: String = "", + text:String = "", + onTextChange: (String) -> Unit = { _ -> {} }, + textFieldFocused: MutableState? = null, + placeholder: String = "", + nextAction: ImeAction = ImeAction.Next, + keyboardType: KeyboardType = KeyboardType.Text, + fontSize: TextUnit = TextUnit.Unspecified, + maxLength: Int = Int.MAX_VALUE, + isMoneyInput: Boolean = false // New parameter to enable money validation +) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Text(title, color = Color.Black, fontSize = 14.sp, fontWeight = FontWeight.Medium) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text(placeholder, color = Color(0xFF999999)) }, + keyboardOptions = KeyboardOptions( + keyboardType = if (isMoneyInput) KeyboardType.Decimal else keyboardType, + imeAction = nextAction + ), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFFEEEEEE), + unfocusedBorderColor = Color(0xFFEEEEEE) + ), + shape = RoundedCornerShape(8.dp) + ) + } +} + +@Preview +@Composable +fun TextInputWithTitleBorderViewPreview() { + TextInputWithTitleBorderView() +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/common/view/WebView.kt b/app/src/main/java/com/yzx/kiosk/ui/common/view/WebView.kt new file mode 100644 index 0000000..3049b05 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/common/view/WebView.kt @@ -0,0 +1,61 @@ +package com.yzx.kiosk.ui.common.view + +import android.graphics.Bitmap +import android.graphics.Color +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import kotlin.apply + +@Composable +fun EnhancedWebView( + url: String, + modifier: Modifier = Modifier, + onPageStarted: (String?) -> Unit = {}, // 页面开始加载回调 + onPageFinished: (String?) -> Unit = {}, // 页面加载完成回调 + onError: () -> Unit = {}, // 加载错误回调 + onProgressChanged: (Int) -> Unit = {} // 加载进度变化回调 +) { + + AndroidView( + modifier = modifier, + factory = { ctx -> + WebView(ctx).apply { + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + onPageStarted(url) + } + + override fun onPageFinished(view: WebView?, url: String?) { + onPageFinished(url) + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + if (request?.isForMainFrame == true) { + onError() + } + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + onProgressChanged(newProgress) + } + } + + setBackgroundColor(Color.TRANSPARENT) + + loadUrl(url) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/crop/MyUCropActivity.kt b/app/src/main/java/com/yzx/kiosk/ui/crop/MyUCropActivity.kt new file mode 100644 index 0000000..d7a553a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/crop/MyUCropActivity.kt @@ -0,0 +1,25 @@ +package com.yzx.kiosk.ui.crop + +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.WindowInsets +import com.yalantis.ucrop.UCropActivity + +class MyUCropActivity : UCropActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+ 用 WindowInsetsController 隐藏状态栏 + window.insetsController?.hide(WindowInsets.Type.statusBars()) + } else { + // 老版本用 systemUiVisibility + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/device/view/AddFileBottomModal.kt b/app/src/main/java/com/yzx/kiosk/ui/device/view/AddFileBottomModal.kt new file mode 100644 index 0000000..e0225b9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/device/view/AddFileBottomModal.kt @@ -0,0 +1,117 @@ +package com.yzx.kiosk.ui.device.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.R +import com.yzx.kiosk.component.button.AppButton +import com.yzx.kiosk.component.modal.BottomModal +import com.yzx.kiosk.theme.BorderLight + +/** + * 任务添加专用的文件选择上拉框(只显示图片和视频) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddFileBottomModalForTask( + visible: Boolean, + onDismiss: () -> Unit, + //选择 1 图片 2视频 + onChoose: (Int) -> Unit, +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + BottomModal( + visible = visible, + onDismiss = onDismiss, + title = "选择上传类型", + sheetState = sheetState, + showDragIndicator = false, + containerColor = Color.White, + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + AddFileBottomContentItemForTask( + title = "上传图片", + resId = R.mipmap.icon_cloud_storage_add_photo, + onClick = { + onDismiss() + onChoose(1) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + AddFileBottomContentItemForTask( + title = "上传视频", + resId = R.mipmap.icon_cloud_storage_add_video, + onClick = { + onDismiss() + onChoose(2) + } + ) + } + HorizontalDivider( + thickness = 0.5.dp, + color = BorderLight, + ) + Box(modifier = Modifier.padding(16.dp)) { + AppButton( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + text = "取消", + textSize = 16.sp, + textColor = Color(0xFF4B5563), + buttonColor = Color(0xFFF4F4F4), + fontWeight = FontWeight.Medium, + onClick = onDismiss, + buttonRadius = 12.dp, + ) + } + } + } +} + +@Composable +fun AddFileBottomContentItemForTask( + title: String, + resId: Int, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 16.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = resId), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = title, + fontSize = 16.sp, + color = Color.Black + ) + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/device/view/DeviceConfigScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/device/view/DeviceConfigScreen.kt new file mode 100644 index 0000000..297d632 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/device/view/DeviceConfigScreen.kt @@ -0,0 +1,1024 @@ +package com.yzx.kiosk.ui.device.view + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.R +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.device.view.AddFileBottomModalForTask +import com.yzx.kiosk.ui.device.viewmodel.DeviceConfigViewModel +import com.yzx.kiosk.ui.device.viewmodel.MediaItem as PosterItem +import android.app.Activity +import com.luck.picture.lib.basic.PictureSelector +import com.luck.picture.lib.config.SelectMimeType +import com.luck.picture.lib.config.SelectModeConfig +import com.luck.picture.lib.entity.LocalMedia +import com.luck.picture.lib.interfaces.OnResultCallbackListener +import com.yzx.kiosk.utils.LogUtils +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceConfigScreen( + viewModel: DeviceConfigViewModel = hiltViewModel() +) { + val deviceSN by viewModel.deviceSN.collectAsState() + val deviceSecret by viewModel.deviceSecret.collectAsState() + val scenicName by viewModel.scenicName.collectAsState() + val servicePhone by viewModel.servicePhone.collectAsState() + val homepageServicePhone by viewModel.homepageServicePhone.collectAsState() + val showWechatScanPrivacy by viewModel.showWechatScanPrivacy.collectAsState() + val homePageLiveEnabled by viewModel.homePageLiveEnabled.collectAsState() + val homeNewStyleEnabled by viewModel.homeNewStyleEnabled.collectAsState() + val posterList by viewModel.posterList.collectAsState() + val selectedPosters by viewModel.selectedPosters.collectAsState() + val audioList by viewModel.audioList.collectAsState() + val selectedAudio by viewModel.selectedAudio.collectAsState() + val playingAudio by viewModel.playingAudio.collectAsState() + val showFileSelectModal by viewModel.showFileSelectModal.collectAsState() + val uploadedCustomMaterials by viewModel.uploadedCustomMaterials.collectAsState() + val showUploadProgressDialog by viewModel.showUploadProgressDialog.collectAsState() + val uploadProgressInfo by viewModel.uploadProgressInfo.collectAsState() + + val context = LocalContext.current + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBar( + backgroundColor = Color.White, + title = "设备配置", + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = false + ) + } + }, + backgroundColor = Color(0xFFF5F5F5) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // 设备基础信息卡片 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "设备基础信息", + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 设备SN编号(只读,不可修改) + Row { + Text(text = "设备 SN 编号 ", fontSize = 21.sp, color = Color.Black, fontWeight = FontWeight.Medium) + Text(text = "*", fontSize = 21.sp, color = Color.Red, fontWeight = FontWeight.Medium) + } + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = deviceSN, + onValueChange = {}, + readOnly = true, + enabled = false, + placeholder = { Text("设备SN编号", color = Color(0xFFCCCCCC)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = Color.Black, + disabledBorderColor = Color(0xFFCCCCCC), + disabledPlaceholderColor = Color(0xFFCCCCCC) + ) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 设备密钥(可输入、可切换明文/密文,点击确定保存) + Text(text = "设备密钥", fontSize = 21.sp, color = Color.Black, fontWeight = FontWeight.Medium) + Spacer(modifier = Modifier.height(12.dp)) + var secretVisible by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField( + value = deviceSecret, + onValueChange = { viewModel.updateDeviceSecret(it) }, + placeholder = { Text("请输入设备密钥", color = Color(0xFFCCCCCC)) }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = RoundedCornerShape(12.dp), + visualTransformation = if (secretVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { secretVisible = !secretVisible }) { + Icon( + imageVector = if (secretVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (secretVisible) "隐藏" else "显示" + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) + ) + Button( + onClick = { viewModel.saveDeviceSecret() }, + shape = RoundedCornerShape(12.dp) + ) { + Text("确定") + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 景区名称(只读,不可编辑) + Text(text = "景区", fontSize = 21.sp, color = Color.Black, fontWeight = FontWeight.Medium) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = scenicName.ifEmpty { "暂无景区信息" }, + onValueChange = {}, + readOnly = true, + enabled = false, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = Color.Black, + disabledBorderColor = Color(0xFFCCCCCC), + disabledPlaceholderColor = Color(0xFFCCCCCC) + ) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 首页客服电话 + Text(text = "首页客服电话", fontSize = 21.sp, color = Color.Black, fontWeight = FontWeight.Medium) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = homepageServicePhone, + onValueChange = { viewModel.updateHomepageServicePhone(it) }, + placeholder = { Text("请输入客服电话", color = Color(0xFFCCCCCC)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 负责人电话 + Text(text = "负责人电话", fontSize = 21.sp, color = Color.Black, fontWeight = FontWeight.Medium) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = servicePhone, + onValueChange = { viewModel.updateServicePhone(it) }, + placeholder = { Text("请输入负责人电话", color = Color(0xFFCCCCCC)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 首页微信扫码隐私取片开关及下级扩展开关(仅本地) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF4F4F4), RoundedCornerShape(12.dp)) + .padding(horizontal = 24.dp, vertical = 18.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "首页微信扫码隐私取片", + fontSize = 21.sp, + color = Color(0xFF4B5563) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "显示", + fontSize = 21.sp, + color = Color.Black + ) + Switch( + checked = showWechatScanPrivacy, + onCheckedChange = { viewModel.updateShowWechatScanPrivacy(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color(0xFF0073FF), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color(0xFFCCCCCC) + ) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF4F4F4), RoundedCornerShape(12.dp)) + .padding(horizontal = 24.dp, vertical = 18.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.device_config_home_page_live), + fontSize = 21.sp, + color = Color(0xFF4B5563) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "开启", + fontSize = 21.sp, + color = Color.Black + ) + Switch( + checked = homePageLiveEnabled, + onCheckedChange = { viewModel.updateHomePageLiveEnabled(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color(0xFF0073FF), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color(0xFFCCCCCC) + ) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF4F4F4), RoundedCornerShape(12.dp)) + .padding(horizontal = 24.dp, vertical = 18.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.device_config_home_new_style), + fontSize = 21.sp, + color = Color(0xFF4B5563) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "开启", + fontSize = 21.sp, + color = Color.Black + ) + Switch( + checked = homeNewStyleEnabled, + onCheckedChange = { viewModel.updateHomeNewStyleEnabled(it) }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color(0xFF0073FF), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color(0xFFCCCCCC) + ) + ) + } + } + } + } + } + + // 海报配置卡片 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "海报配置", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row { + Text(text = "选择默认海报(最多选", fontSize = 21.sp, color = Color.Black) + Text(text = "5", fontSize = 21.sp, color = Color(0xFF2196F3)) + Text(text = "张)", fontSize = 21.sp, color = Color.Black) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // 海报网格列表 - 每行3张,长图比例 16:9(高:宽) + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val horizontalPadding = 64.dp // 卡片padding 16dp * 2 + 内部padding 16dp * 2 + val spacing = 8.dp + val itemWidth = (screenWidth - horizontalPadding - spacing * 2) / 3 + val itemHeight = itemWidth * 16 / 9 // 长图:高度是宽度的16/9 + + // 计算行数 + val rowCount = (posterList.size + 2) / 3 + val gridHeight = itemHeight * rowCount + spacing * (rowCount - 1).coerceAtLeast(0) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .height(gridHeight), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + userScrollEnabled = false + ) { + itemsIndexed(posterList) { index, poster -> + val selectionIndex = selectedPosters.indexOf(poster.url) + PosterGridItem( + poster = poster, + selectionIndex = if (selectionIndex >= 0) selectionIndex + 1 else -1, + itemHeight = itemHeight, + onSelect = { viewModel.togglePosterSelection(poster.url) } + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text(text = "上传自定义素材", fontSize = 21.sp, color = Color.Black) + + Spacer(modifier = Modifier.height(12.dp)) + + // 上传区域 + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .border( + width = 1.dp, + color = Color(0xFFE0E0E0), + shape = RoundedCornerShape(12.dp) + ) + .clip(RoundedCornerShape(12.dp)) + .clickable { viewModel.showFileSelectModal() }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(48.dp) + .background(Color(0xFF2196F3), RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = Color.White + ) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = "点击上传图片或视频", + fontSize = 21.sp, + color = Color(0xFF4B5563) + ) + Text( + text = "建议尺寸:1920x1080px,大小不超过500MB", + fontSize = 18.sp, + color = Color(0xFFB6BECA) + ) + } + } + + // 显示已上传的自定义素材 + if (uploadedCustomMaterials.isNotEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "已上传自定义素材(${uploadedCustomMaterials.size}个)", + fontSize = 21.sp, + color = Color(0xFF666666) + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 提示音配置卡片 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "提示音配置", + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text(text = "选择默认提示音", fontSize = 21.sp, color = Color.Black, fontWeight = FontWeight.Medium) + + Spacer(modifier = Modifier.height(12.dp)) + + // 提示音列表 + audioList.forEachIndexed { index, audio -> + val isSelected = selectedAudio == audio + val isPlaying = playingAudio == audio + + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (isSelected) Color(0xFF2196F3) else Color(0xFFF5F5F5), + RoundedCornerShape(8.dp) + ) + .clickable { viewModel.toggleAudioSelection(audio) } + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "欢迎语音 ${index + 1}", + fontSize = 21.sp, + color = if (isSelected) Color.White else Color.Black + ) + + IconButton( + onClick = { viewModel.playAudio(audio) } + ) { + Icon( + if (isPlaying) Icons.Default.Close else Icons.Default.PlayArrow, + contentDescription = null, + tint = if (isSelected) Color.White else Color(0xFF666666) + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text(text = "上传自定义提示音", fontSize = 21.sp, color = Color.Black) + + Spacer(modifier = Modifier.height(12.dp)) + + // 上传音频区域 + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .border( + width = 1.dp, + color = Color(0xFFE0E0E0), + shape = RoundedCornerShape(12.dp) + ) + .clip(RoundedCornerShape(12.dp)) + .clickable { viewModel.selectAudioFile() }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(48.dp) + .background(Color(0xFF2196F3), RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = Color.White + ) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = "点击上传音频", + fontSize = 21.sp, + color = Color(0xFF4B5563) + ) + Text( + text = "支持 MP3/WAV 格式,大小不超过20MB", + fontSize = 18.sp, + color = Color(0xFFB6BECA) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 保存按钮 + Button( + onClick = { viewModel.saveConfig() }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .height(72.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2196F3)) + ) { + Text(text = "保存", fontSize = 24.sp, color = Color.White) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + + // 文件选择弹窗 + AddFileBottomModalForTask( + visible = showFileSelectModal, + onDismiss = { viewModel.hideFileSelectModal() }, + onChoose = { fileType -> + // fileType: 1-图片, 2-视频 + viewModel.onFileTypeSelected(fileType) + } + ) + + // PictureSelector 结果处理 + PictureSelectorResultHandler( + viewModel = viewModel, + context = context + ) + + // 音频文件选择器 + val audioPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris: List -> + try { + LogUtils.d("DeviceConfigScreen", "收到音频文件选择结果,数量: ${uris.size}") + if (uris.isNotEmpty()) { + viewModel.handleSelectedAudioFiles(uris) + } else { + LogUtils.d("DeviceConfigScreen", "音频文件选择为空") + } + } catch (e: Exception) { + LogUtils.e("DeviceConfigScreen", "处理音频文件选择结果异常: ${e.message}") + e.printStackTrace() + } finally { + viewModel.clearPendingFileType() + } + } + + // 监听音频文件选择请求 + val pendingFileType by viewModel.pendingFileType.collectAsState() + LaunchedEffect(pendingFileType) { + LogUtils.d("DeviceConfigScreen", "LaunchedEffect 触发,pendingFileType: $pendingFileType") + if (pendingFileType == 3) { + LogUtils.d("DeviceConfigScreen", "启动音频文件选择器") + // 延迟一下,确保状态已更新 + kotlinx.coroutines.delay(50) + try { + audioPickerLauncher.launch("audio/*") + } catch (e: Exception) { + LogUtils.e("DeviceConfigScreen", "启动音频文件选择器失败: ${e.message}") + e.printStackTrace() + viewModel.clearPendingFileType() + } + } + } + + // 上传进度对话框 + if (showUploadProgressDialog) { + UploadProgressDialog( + progressInfo = uploadProgressInfo, + onDismiss = { viewModel.closeUploadProgressDialog() } + ) + } + } +} + +/** + * PictureSelector 结果处理器 + * 参考 TaskAddViewModel,使用 forResult 回调方式 + */ +@Composable +private fun PictureSelectorResultHandler( + viewModel: DeviceConfigViewModel, + context: android.content.Context +) { + val pendingFileType by viewModel.pendingFileType.collectAsState() + + // 处理文件选择结果的通用方法 + fun handleSelectedFiles(mediaList: ArrayList, fileType: Int) { + if (mediaList.isEmpty()) return + + try { + // 限制处理数量,避免一次性处理过多文件导致内存溢出 + val limitedList = mediaList.take(9) + // 处理所有选中的文件,转换为 Uri 列表 + val fileUris = limitedList.mapNotNull { media -> + // 获取文件路径(参考 TaskAddViewModel,优先使用 availablePath) + val path = media.availablePath + ?: media.realPath + ?: media.path + if (path != null) { + try { + // 将路径转换为 Uri(参考 TaskAddViewModel) + when { + path.startsWith("content://") -> android.net.Uri.parse(path) + path.startsWith("file://") -> android.net.Uri.parse(path) + else -> { + val file = java.io.File(path) + if (file.exists()) { + android.net.Uri.fromFile(file) + } else { + android.net.Uri.parse("file://$path") + } + } + } + } catch (e: Exception) { + LogUtils.e("DeviceConfigScreen", "构建文件URI失败: ${e.message}") + null + } + } else { + null + } + } + if (fileUris.isNotEmpty()) { + // fileType: 1-视频, 2-图片(UploadTaskEntity 的定义) + viewModel.handleSelectedFiles(fileUris, fileType) + } + } catch (e: OutOfMemoryError) { + LogUtils.e("DeviceConfigScreen", "处理文件选择结果内存溢出: ${e.message}") + System.gc() + } catch (e: Exception) { + LogUtils.e("DeviceConfigScreen", "处理文件选择结果异常: ${e.message}") + } + } + + // 监听待打开的文件类型,打开对应的选择器 + // 使用 key 确保只在 pendingFileType 变化时执行 + LaunchedEffect(pendingFileType) { + val currentFileType = pendingFileType + if (currentFileType != null && context is Activity) { + try { + // 音频文件选择在 DeviceConfigScreen 中使用 LaunchedEffect 处理 + if (currentFileType != 3) { + // 图片或视频选择,使用 PictureSelector + val selectMimeType = if (currentFileType == 1) { + SelectMimeType.ofImage() + } else { + SelectMimeType.ofVideo() + } + + // 参考 TaskAddViewModel,使用 forResult 回调方式 + // fileType: 1-视频, 2-图片(UploadTaskEntity 的定义) + val uploadFileType = if (currentFileType == 1) 2 else 1 + + PictureSelector.create(context) + .openGallery(selectMimeType) + .isDisplayCamera(true) + .isPreviewVideo(true) + .isPreviewImage(true) + .setSelectionMode(SelectModeConfig.MULTIPLE) // 多选 + .setMaxSelectNum(9) // 最多选择9个 + .setImageEngine(com.yzx.kiosk.utils.GlideEngine.createGlideEngine()) + .forResult(object : OnResultCallbackListener { + override fun onResult(result: ArrayList) { + viewModel.clearPendingFileType() + handleSelectedFiles(result, uploadFileType) + } + + override fun onCancel() { + viewModel.clearPendingFileType() + // 用户取消选择 + } + }) + } + } catch (e: Exception) { + LogUtils.e("DeviceConfigScreen", "打开文件选择器异常: ${e.message}") + viewModel.clearPendingFileType() + } + } + } +} + +/** + * 海报网格项 - 支持图片和视频,16:9比例 + */ +@Composable +fun PosterGridItem( + poster: PosterItem, + selectionIndex: Int, // -1表示未选中,>=1表示选中序号 + itemHeight: androidx.compose.ui.unit.Dp, + onSelect: () -> Unit +) { + val context = LocalContext.current + val isSelected = selectionIndex > 0 + + // 视频播放状态 + var isPlaying by remember { mutableStateOf(false) } + var exoPlayer by remember { mutableStateOf(null) } + + // 当组件销毁时释放播放器 + DisposableEffect(poster.url) { + onDispose { + exoPlayer?.release() + exoPlayer = null + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight) + .clip(RoundedCornerShape(8.dp)) + .border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) Color(0xFF2196F3) else Color(0xFFE0E0E0), + shape = RoundedCornerShape(8.dp) + ) + .clickable(enabled = !poster.isUploading) { onSelect() } + ) { + // 上传进度遮罩层 + if (poster.isUploading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + progress = { poster.uploadProgress / 100f }, + modifier = Modifier.size(48.dp), + color = Color.White, + trackColor = Color.White.copy(alpha = 0.3f), + strokeWidth = 4.dp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "${poster.uploadProgress}%", + color = Color.White, + fontSize = 12.sp + ) + } + } + } + if (poster.isVideo) { + // 视频组件 + if (isPlaying && exoPlayer != null) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = false + } + }, + modifier = Modifier.fillMaxSize() + ) + } else { + // 视频封面(使用黑色背景) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF1A1A2E)) + ) + } + + // 视频播放/暂停按钮 + Box( + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + .background(Color.White.copy(alpha = 0.8f), RoundedCornerShape(18.dp)) + .clickable { + if (isPlaying) { + exoPlayer?.pause() + isPlaying = false + } else { + if (exoPlayer == null) { + exoPlayer = ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(Uri.parse(poster.url))) + prepare() + repeatMode = Player.REPEAT_MODE_ONE + } + } + exoPlayer?.play() + isPlaying = true + } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Close else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "暂停" else "播放", + tint = Color(0xFF2196F3), + modifier = Modifier.size(20.dp) + ) + } + } else { + // 图片组件 + AsyncImage( + model = poster.url, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + // 左上角文件类型标签 + Box( + modifier = Modifier + .align(Alignment.TopStart) + .background(Color(0xFF2196F3), RoundedCornerShape(topStart = 8.dp, bottomEnd = 4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = poster.extension.uppercase(), + fontSize = 10.sp, + color = Color.White + ) + } + + // 右上角选中序号 + if (isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(20.dp) + .background(Color(0xFF2196F3), RoundedCornerShape(10.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = selectionIndex.toString(), + fontSize = 10.sp, + color = Color.White + ) + } + } + } +} + +/** + * 上传进度对话框 + */ +@Composable +private fun UploadProgressDialog( + progressInfo: DeviceConfigViewModel.UploadProgressInfo, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "上传进度", + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 总进度:1/3 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "总进度", + fontSize = 14.sp, + color = Color.Black + ) + Text( + text = "${progressInfo.completedCount}/${progressInfo.totalCount}", + fontSize = 14.sp, + color = Color(0xFF2196F3), + fontWeight = FontWeight.Bold + ) + } + + // 当前上传文件信息 + if (progressInfo.currentFileName != null) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "正在上传: ${progressInfo.currentFileName}", + fontSize = 12.sp, + color = Color(0xFF666666), + maxLines = 1, + modifier = Modifier.fillMaxWidth() + ) + + // 当前文件进度条 + LinearProgressIndicator( + progress = { progressInfo.currentProgress / 100f }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = Color(0xFF2196F3), + trackColor = Color(0xFFE0E0E0) + ) + + // 进度百分比 + Text( + text = "${progressInfo.currentProgress}%", + fontSize = 12.sp, + color = Color(0xFF2196F3), + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.End + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("确定") + } + }, + containerColor = Color.White, + shape = RoundedCornerShape(16.dp) + ) +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/device/viewmodel/DeviceConfigViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/device/viewmodel/DeviceConfigViewModel.kt new file mode 100644 index 0000000..29fb354 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/device/viewmodel/DeviceConfigViewModel.kt @@ -0,0 +1,1574 @@ +package com.yzx.kiosk.ui.device.viewmodel + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.media.MediaPlayer +import android.net.Uri +import android.os.Build +import android.provider.OpenableColumns +import android.provider.Settings +import androidx.lifecycle.viewModelScope +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.network.enums.UploadStatus +import com.yzx.kiosk.network.manager.UploadManager +import com.yzx.kiosk.network.model.entity.UploadTaskEntity +import com.yzx.kiosk.network.model.request.SetConfigRequest +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import java.io.File +import java.util.UUID +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +/** + * 景区数据类 + */ +data class Scenic( + val id: String, + val name: String +) + +/** + * 媒体项数据类 + */ +data class MediaItem( + val url: String, + val isVideo: Boolean, + val extension: String, + val isUploading: Boolean = false, // 是否正在上传 + val uploadProgress: Int = 0, // 上传进度 0-100 + val uploadTaskId: String? = null // 上传任务ID +) + +@HiltViewModel +class DeviceConfigViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val appStoreDataSource: AppStoreDataSource, + private val networkRepository: NetWorkRepository, + private val uploadManager: UploadManager, + @ApplicationContext private val context: Context +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + companion object { + private const val TAG = "DeviceConfigViewModel" + private const val MAX_POSTER_COUNT = 5 + private const val MAX_UPLOAD_COUNT = 9 // 最多选择9张 + } + + // 设备SN + private val _deviceSN = MutableStateFlow("") + val deviceSN: StateFlow = _deviceSN.asStateFlow() + + // 设备密钥(可编辑,点击确定后保存到本地) + private val _deviceSecret = MutableStateFlow("") + val deviceSecret: StateFlow = _deviceSecret.asStateFlow() + + // 景区列表 + private val _scenicList = MutableStateFlow>(emptyList()) + val scenicList: StateFlow> = _scenicList.asStateFlow() + + // 选中的景区 + private val _selectedScenic = MutableStateFlow(null) + val selectedScenic: StateFlow = _selectedScenic.asStateFlow() + + // 景区名称(从接口获取,只读) + private val _scenicName = MutableStateFlow("") + val scenicName: StateFlow = _scenicName.asStateFlow() + + // 客服电话(负责人电话) + private val _servicePhone = MutableStateFlow("") + val servicePhone: StateFlow = _servicePhone.asStateFlow() + + // 首页客服电话 + private val _homepageServicePhone = MutableStateFlow("") + val homepageServicePhone: StateFlow = _homepageServicePhone.asStateFlow() + + // 首页微信扫码隐私取片开关 + private val _showWechatScanPrivacy = MutableStateFlow(true) + val showWechatScanPrivacy: StateFlow = _showWechatScanPrivacy.asStateFlow() + + // 首页是否开启直播、新首页样式(仅本地,默认关闭) + private val _homePageLiveEnabled = MutableStateFlow(false) + val homePageLiveEnabled: StateFlow = _homePageLiveEnabled.asStateFlow() + private val _homeNewStyleEnabled = MutableStateFlow(false) + val homeNewStyleEnabled: StateFlow = _homeNewStyleEnabled.asStateFlow() + + // 海报列表 + private val _posterList = MutableStateFlow>(emptyList()) + val posterList: StateFlow> = _posterList.asStateFlow() + + // 选中的海报 + private val _selectedPosters = MutableStateFlow>(emptyList()) + val selectedPosters: StateFlow> = _selectedPosters.asStateFlow() + + // 音频列表 + private val _audioList = MutableStateFlow>(emptyList()) + val audioList: StateFlow> = _audioList.asStateFlow() + + // 选中的音频 + private val _selectedAudio = MutableStateFlow(null) + val selectedAudio: StateFlow = _selectedAudio.asStateFlow() + + // 正在播放的音频 + private val _playingAudio = MutableStateFlow(null) + val playingAudio: StateFlow = _playingAudio.asStateFlow() + + // 上传的自定义素材文件地址列表(限制最大数量,避免内存溢出) + private val _uploadedCustomMaterials = MutableStateFlow>(emptyList()) + val uploadedCustomMaterials: StateFlow> = _uploadedCustomMaterials.asStateFlow() + + // 新上传的提示音列表 + private val _uploadedCustomBgm = MutableStateFlow>(emptyList()) + val uploadedCustomBgm: StateFlow> = _uploadedCustomBgm.asStateFlow() + + // 从接口获取的原始数据(用于计算显示列表) + private val _originalPosters = MutableStateFlow>(emptyList()) // 从接口获取的 posters + private val _originalAllPosters = MutableStateFlow>(emptyList()) // 从接口获取的 all_posters + private val _originalBgm = MutableStateFlow(null) // 从接口获取的 bgm + private val _originalAllBgm = MutableStateFlow>(emptyList()) // 从接口获取的 all_bgm + + // 是否显示文件选择弹窗 + private val _showFileSelectModal = MutableStateFlow(false) + val showFileSelectModal: StateFlow = _showFileSelectModal.asStateFlow() + + // 待打开的文件选择类型(1-图片,2-视频) + private val _pendingFileType = MutableStateFlow(null) + val pendingFileType: StateFlow = _pendingFileType.asStateFlow() + + // 上传进度对话框状态 + private val _showUploadProgressDialog = MutableStateFlow(false) + val showUploadProgressDialog: StateFlow = _showUploadProgressDialog.asStateFlow() + + // 上传进度信息 + data class UploadProgressInfo( + val totalCount: Int = 0, // 总文件数 + val completedCount: Int = 0, // 已上传完成数 + val currentFileName: String? = null, // 当前正在上传的文件名 + val currentProgress: Int = 0 // 当前文件上传进度 0-100 + ) + private val _uploadProgressInfo = MutableStateFlow(UploadProgressInfo()) + val uploadProgressInfo: StateFlow = _uploadProgressInfo.asStateFlow() + + // 当前批次的上传任务ID列表(用于跟踪总进度) + private val _currentBatchTaskIds = MutableStateFlow>(emptySet()) + + private var mediaPlayer: MediaPlayer? = null + + init { + loadData() + } + + private fun loadData() { + // 加载设备SN(从本机获取,不可修改) + _deviceSN.value = getDeviceSerialNumber() + + // 加载景区名称(从本地读取,接口会更新) + _scenicName.value = appStoreDataSource.getScenicName() + + // 加载客服电话(负责人电话) + _servicePhone.value = appStoreDataSource.getServicePhone() + + // 加载首页客服电话 + _homepageServicePhone.value = appStoreDataSource.getHomepageServicePhone() + + // 加载设备密钥 + _deviceSecret.value = appStoreDataSource.getDeviceSecret() + + // 加载首页微信扫码隐私取片开关状态 + _showWechatScanPrivacy.value = appStoreDataSource.getShowWechatScanPrivacy() + _homePageLiveEnabled.value = appStoreDataSource.getHomePageLiveEnabled() + _homeNewStyleEnabled.value = appStoreDataSource.getHomeNewStyleEnabled() + + // 加载已选海报(从本地读取,接口会更新) + // 注意:初始化时从本地读取,但接口返回后会覆盖,所以这里只是临时值 + val savedPosters = appStoreDataSource.getPosterList() + _selectedPosters.value = savedPosters + LogUtils.d(TAG, "初始化加载已选海报: 数量=${savedPosters.size}, 列表=$savedPosters") + + // 加载已选音频(从本地读取,接口会更新) + val savedAudio = appStoreDataSource.getBgm() + if (savedAudio.isNotEmpty()) { + _selectedAudio.value = savedAudio + } + + // 调用接口获取配置信息(接口返回后会更新 _selectedPosters) + loadEquipmentConfig() + } + + /** + * 加载设备配置 + */ + private fun loadEquipmentConfig() { + viewModelScope.launch { + handleResultWithData( + flow = networkRepository.getEquipmentConfig().asResult(), + onData = { config -> + // 保存并更新 bind_box_url + if (config.bindBoxUrl != null) { + appStoreDataSource.saveBindBoxUrl(config.bindBoxUrl) + } + + // 保存并更新 bind_box_lan_url + if (config.bindBoxLanUrl != null) { + appStoreDataSource.saveBindBoxLanUrl(config.bindBoxLanUrl) + } + + // 保存 bind_box_sn + if (config.bindBoxSn != null) { + appStoreDataSource.saveBindBoxSn(config.bindBoxSn) + } + + // 保存 bind_box_api_token (同时作为 Face Search Token) + if (config.bindBoxApiToken != null) { + appStoreDataSource.saveBindBoxApiToken(config.bindBoxApiToken) + appStoreDataSource.saveFaceSearchToken(config.bindBoxApiToken) + } + + // 保存 bind_box_lan_ip + if (config.bindBoxLanIp != null) { + appStoreDataSource.saveBindBoxLanIp(config.bindBoxLanIp) + } + + // 保存并更新 scenic_id + if (config.scenicId > 0) { + appStoreDataSource.saveScenicId(config.scenicId.toString()) + } + + // 保存并更新 scenic_name + if (config.scenicName != null) { + _scenicName.value = config.scenicName + appStoreDataSource.saveScenicName(config.scenicName) + } + + // 更新客服电话(负责人电话) + if (config.serviceTel != null) { + _servicePhone.value = config.serviceTel + appStoreDataSource.saveServicePhone(config.serviceTel) + } + + // 更新首页客服电话 + if (config.servicePhone != null) { + _homepageServicePhone.value = config.servicePhone + appStoreDataSource.saveHomepageServicePhone(config.servicePhone) + } + + // 更新首页微信扫码隐私取片开关状态 + if (config.isShowQRCode != null) { + _showWechatScanPrivacy.value = config.isShowQRCode + appStoreDataSource.saveShowWechatScanPrivacy(config.isShowQRCode) + } + + // 保存原始数据 + _originalPosters.value = config.posters ?: emptyList() + _originalAllPosters.value = config.allPosters ?: emptyList() + _originalBgm.value = config.bgm + _originalAllBgm.value = config.allBgm ?: emptyList() + + // 计算显示的海报列表:posters + all_posters + 新上传的海报的并集 + val allDisplayPosters = (_originalPosters.value + _originalAllPosters.value + _uploadedCustomMaterials.value).distinct() + // 保留正在上传的项目,只更新非上传中的项目 + val uploadingItems = _posterList.value.filter { it.isUploading } + val posterItems = allDisplayPosters.map { url -> + val extension = url.substringAfterLast(".").lowercase() + val isVideo = extension in listOf("mp4", "avi", "mov", "mkv", "webm") + MediaItem(url, isVideo, extension) + } + _posterList.value = posterItems + uploadingItems + + // 更新已选海报(从 posters 获取) + // _selectedPosters 只从获取配置接口和用户手动选择保存时更新 + if (config.posters != null) { + val beforeUpdate = _selectedPosters.value.toList() + LogUtils.d(TAG, "从接口更新已选海报: 接口返回数量=${config.posters.size}, 列表=${config.posters}") + LogUtils.d(TAG, "更新前已选海报: 数量=${beforeUpdate.size}, 列表=$beforeUpdate") + + // 限制接口返回的海报数量不超过 MAX_POSTER_COUNT(5个) + // 如果接口返回的海报数量超过5个,只取前5个(可能是历史数据问题) + val validPosters = if (config.posters.size > MAX_POSTER_COUNT) { + LogUtils.i(TAG, "警告:接口返回的海报数量(${config.posters.size})超过最大限制($MAX_POSTER_COUNT),只取前${MAX_POSTER_COUNT}个") + config.posters.take(MAX_POSTER_COUNT) + } else { + config.posters + } + + _selectedPosters.value = validPosters + appStoreDataSource.savePosterList(validPosters) + LogUtils.d(TAG, "已选海报更新完成: 当前数量=${_selectedPosters.value.size}, 列表=${_selectedPosters.value}") + + if (beforeUpdate != _selectedPosters.value) { + LogUtils.d(TAG, "已选海报已更新: 从 $beforeUpdate 更新为 ${_selectedPosters.value}") + } + } else { + LogUtils.d(TAG, "接口返回的 posters 为 null,保持当前已选海报不变: 数量=${_selectedPosters.value.size}, 列表=${_selectedPosters.value}") + } + + // 计算显示的提示音列表:bgm + all_bgm + 新上传的提示音的并集 + val allDisplayBgm = mutableListOf() + if (_originalBgm.value != null && _originalBgm.value!!.isNotEmpty()) { + allDisplayBgm.add(_originalBgm.value!!) + } + allDisplayBgm.addAll(_originalAllBgm.value) + allDisplayBgm.addAll(_uploadedCustomBgm.value) + val distinctDisplayBgm = allDisplayBgm.distinct() + // 只有在列表真正发生变化时才更新,避免不必要的UI刷新 + val currentAudioList = _audioList.value + if (currentAudioList.size != distinctDisplayBgm.size || + currentAudioList.toSet() != distinctDisplayBgm.toSet()) { + _audioList.value = distinctDisplayBgm + LogUtils.d(TAG, "loadEquipmentConfig - 音频列表已更新: 旧数量=${currentAudioList.size}, 新数量=${distinctDisplayBgm.size}") + } else { + LogUtils.d(TAG, "loadEquipmentConfig - 音频列表未变化,跳过更新") + } + + // 更新已选提示音(从 bgm 获取) + if (config.bgm != null && config.bgm.isNotEmpty()) { + _selectedAudio.value = config.bgm + appStoreDataSource.saveBgm(config.bgm) + } + + appStoreDataSource.saveButtonPrintUrl( + config.globalConfig?.buttonPrint?.trim().orEmpty() + ) + + LogUtils.d(TAG, "设备配置加载成功") + }, + onError = { msg, throwable -> + LogUtils.e( "获取设备配置失败: $msg") + } + ) + } + } + + /** + * 获取设备序列号 + */ + private fun getDeviceSerialNumber(): String { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "" + } else { + @Suppress("DEPRECATION") + Build.SERIAL + } + } catch (e: Exception) { + LogUtils.e(TAG, "获取设备SN失败: ${e.message}") + "" + } + } + + fun updateServicePhone(phone: String) { + _servicePhone.value = phone + } + + /** + * 更新首页客服电话 + */ + fun updateHomepageServicePhone(phone: String) { + _homepageServicePhone.value = phone + } + + /** 更新设备密钥(仅更新界面状态,需点击确定后保存) */ + fun updateDeviceSecret(secret: String) { + _deviceSecret.value = secret + } + + /** 将当前输入的设备密钥保存到本地 */ + fun saveDeviceSecret() { + appStoreDataSource.saveDeviceSecret(_deviceSecret.value) + ToastUtils.show("设备密钥已保存") + } + + /** + * 更新首页微信扫码隐私取片开关状态 + */ + fun updateShowWechatScanPrivacy(show: Boolean) { + _showWechatScanPrivacy.value = show + } + + fun updateHomePageLiveEnabled(enabled: Boolean) { + _homePageLiveEnabled.value = enabled + appStoreDataSource.saveHomePageLiveEnabled(enabled) + } + + fun updateHomeNewStyleEnabled(enabled: Boolean) { + _homeNewStyleEnabled.value = enabled + appStoreDataSource.saveHomeNewStyleEnabled(enabled) + } + + /** + * 切换海报选中状态 + */ + fun togglePosterSelection(url: String) { + val currentList = _selectedPosters.value.toMutableList() + LogUtils.d(TAG, "切换海报选择 - 开始: url=$url, 当前已选择数量=${currentList.size}, 当前列表=$currentList") + + if (currentList.contains(url)) { + // 取消选择 + currentList.remove(url) + LogUtils.d(TAG, "取消选择海报: url=$url, 更新后数量=${currentList.size}") + } else { + // 尝试选择新海报 + // 检查当前已选择的数量(只计算用户手动选择的海报,不包括新上传的) + LogUtils.d(TAG, "尝试选择海报: url=$url, 当前数量=${currentList.size}, 最大限制=$MAX_POSTER_COUNT") + if (currentList.size >= MAX_POSTER_COUNT) { + LogUtils.i(TAG, "已达到最大选择数量限制: ${currentList.size} >= $MAX_POSTER_COUNT, 列表=$currentList") + ToastUtils.show("最多选择${MAX_POSTER_COUNT}张海报") + return + } + currentList.add(url) + LogUtils.d(TAG, "成功选择海报: url=$url, 更新后数量=${currentList.size}") + } + _selectedPosters.value = currentList + LogUtils.d(TAG, "切换海报选择 - 完成: 最终数量=${currentList.size}, 最终列表=$currentList") + } + + /** + * 切换音频选中状态(点击选中,再次点击取消选中) + */ + fun toggleAudioSelection(url: String) { + val currentSelected = _selectedAudio.value + if (currentSelected == url) { + // 如果当前已选中,则取消选中 + _selectedAudio.value = null + LogUtils.d(TAG, "取消选择音频: url=$url") + } else { + // 如果未选中,则选中 + _selectedAudio.value = url + LogUtils.d(TAG, "选择音频: url=$url") + } + } + + /** + * 播放/停止音频 + */ + fun playAudio(url: String) { + viewModelScope.launch { + try { + if (_playingAudio.value == url) { + // 停止播放 + mediaPlayer?.stop() + mediaPlayer?.release() + mediaPlayer = null + _playingAudio.value = null + } else { + // 停止之前的播放 + mediaPlayer?.stop() + mediaPlayer?.release() + + // 开始新的播放 + mediaPlayer = MediaPlayer().apply { + setDataSource(url) + prepareAsync() + setOnPreparedListener { start() } + setOnCompletionListener { + _playingAudio.value = null + } + setOnErrorListener { _, _, _ -> + _playingAudio.value = null + ToastUtils.show("播放失败") + true + } + } + _playingAudio.value = url + } + } catch (e: Exception) { + LogUtils.e(TAG, "播放音频失败: ${e.message}") + ToastUtils.show("播放失败") + } + } + } + + /** + * 显示文件选择弹窗 + */ + fun showFileSelectModal() { + _showFileSelectModal.value = true + } + + /** + * 隐藏文件选择弹窗 + */ + fun hideFileSelectModal() { + _showFileSelectModal.value = false + } + + /** + * 处理文件类型选择(1-图片,2-视频) + */ + fun onFileTypeSelected(fileType: Int) { + hideFileSelectModal() + _pendingFileType.value = fileType + } + + /** + * 选择音频文件 + */ + fun selectAudioFile() { + LogUtils.d(TAG, "selectAudioFile 被调用") + _pendingFileType.value = 3 // 使用 3 表示音频 + LogUtils.d(TAG, "pendingFileType 已设置为 3,当前值: ${_pendingFileType.value}") + } + + /** + * 处理选择的音频文件并上传 + * @param fileUris 音频文件 Uri 列表 + */ + fun handleSelectedAudioFiles(fileUris: List) { + LogUtils.d(TAG, "handleSelectedAudioFiles 被调用,文件数量: ${fileUris.size}") + if (fileUris.isEmpty()) { + LogUtils.d(TAG, "文件列表为空") + ToastUtils.show("未选择文件") + return + } + + // 检查已上传的音频数量,最多9个 + val currentUploadedCount = _uploadedCustomBgm.value.size + if (currentUploadedCount >= MAX_UPLOAD_COUNT) { + ToastUtils.show("本次已上传${MAX_UPLOAD_COUNT}个音频,请保存后再添加") + return + } + + // 计算本次可以上传的数量(不超过9个) + val remainingCount = MAX_UPLOAD_COUNT - currentUploadedCount + if (fileUris.size > remainingCount) { + ToastUtils.show("本次最多还可上传${remainingCount}个文件(已上传${currentUploadedCount}个)") + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val scenicId = appStoreDataSource.getScenicId() + if (scenicId.isEmpty()) { + withContext(Dispatchers.Main) { + ToastUtils.show("景区ID为空,无法上传") + } + return@launch + } + + // 创建上传任务列表 + val uploadTasks = mutableListOf() + + for (uri in fileUris) { + try { + // 解析文件信息 + val fileMeta = resolveFileMeta(uri) + if (fileMeta == null) { + LogUtils.e(TAG, "无法读取文件信息: $uri") + continue + } + val (fileName, fileSize) = fileMeta + + // 检查文件大小(音频文件限制20MB) + val maxFileSize = 20L * 1024 * 1024 // 20MB + if (fileSize > maxFileSize) { + LogUtils.e(TAG, "文件过大: $fileName, 大小: ${fileSize / 1024 / 1024}MB") + withContext(Dispatchers.Main) { + ToastUtils.show("文件 $fileName 超过20MB限制") + } + continue + } + + // 检查文件格式(只允许 MP3 和 WAV) + val extension = fileName.substringAfterLast(".", "").lowercase() + if (extension !in listOf("mp3", "wav")) { + LogUtils.e(TAG, "不支持的音频格式: $fileName") + withContext(Dispatchers.Main) { + ToastUtils.show("文件 $fileName 格式不支持,仅支持 MP3/WAV 格式") + } + continue + } + + // 创建上传任务 + val taskId = UUID.randomUUID().toString() + val task = UploadTaskEntity( + id = taskId, + fileName = fileName, + fileType = 3, // 使用 2(图片)作为 fileType,因为 UploadTaskEntity 只支持 1 和 2 + fileSize = fileSize, + filePath = uri.toString(), + scenicId = scenicId, + folderId = 0, // 自定义素材使用 folderId = 0 + status = UploadStatus.PENDING, + moduleType = "bgm" // 标识为音频上传 + ) + uploadTasks.add(task) + } catch (e: Exception) { + LogUtils.e(TAG, "创建音频上传任务失败: ${e.message}") + // 继续处理下一个文件 + } + } + + if (uploadTasks.isEmpty()) { + withContext(Dispatchers.Main) { + ToastUtils.show("没有有效的文件可上传") + } + return@launch + } + + // 添加上传任务到队列 + uploadManager.addTasksToQueue(uploadTasks) + + // 记录当前批次的任务ID,用于跟踪总进度 + val taskIds = uploadTasks.map { it.id }.toSet() + withContext(Dispatchers.Main) { + _currentBatchTaskIds.value = taskIds + _uploadProgressInfo.value = UploadProgressInfo( + totalCount = uploadTasks.size, + completedCount = 0, + currentFileName = uploadTasks.firstOrNull()?.fileName, + currentProgress = 0 + ) + _showUploadProgressDialog.value = true + } + + // 启动上传完成监听(如果还没有启动) + startUploadProgressMonitoring() + + } catch (e: Exception) { + LogUtils.e(TAG, "处理音频文件上传失败: ${e.message}") + withContext(Dispatchers.Main) { + ToastUtils.show("处理文件失败: ${e.message}") + } + } + } + } + + /** + * 清除待打开的文件类型 + */ + fun clearPendingFileType() { + _pendingFileType.value = null + } + + /** + * 处理选择的文件并上传(参考 TaskAddViewModel) + * @param fileUris 文件 Uri 列表 + * @param fileType 文件类型(1-视频,2-图片) + */ + fun handleSelectedFiles(fileUris: List, fileType: Int) { + if (fileUris.isEmpty()) { + ToastUtils.show("未选择文件") + return + } + + // 检查已上传的海报数量,最多9个 + val currentUploadedCount = _uploadedCustomMaterials.value.size + if (currentUploadedCount >= MAX_UPLOAD_COUNT) { + ToastUtils.show("本次已上传${MAX_UPLOAD_COUNT}个海报,请保存后再添加") + return + } + + // 计算本次可以上传的数量(不超过9个) + val remainingCount = MAX_UPLOAD_COUNT - currentUploadedCount + if (fileUris.size > remainingCount) { + ToastUtils.show("本次最多还可上传${remainingCount}个文件(已上传${currentUploadedCount}个)") + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + val scenicId = appStoreDataSource.getScenicId() + if (scenicId.isEmpty()) { + withContext(Dispatchers.Main) { + ToastUtils.show("景区ID为空,无法上传") + } + return@launch + } + + // 创建上传任务列表(使用 withContext 避免在主线程创建大量对象) + // 优化:分批处理,避免一次性创建过多对象 + val uploadTasks = mutableListOf() + var processedCount = 0 + + for (uri in fileUris) { + try { + // 解析文件信息(参考 TaskAddViewModel.resolveFileMeta) + val fileMeta = resolveFileMeta(uri) + if (fileMeta == null) { + LogUtils.e(TAG, "无法读取文件信息: $uri") + continue + } + val (fileName, fileSize) = fileMeta + + // 检查文件大小(避免上传过大文件导致内存溢出) + val maxFileSize = 500L * 1024 * 1024 // 500MB + if (fileSize > maxFileSize) { + LogUtils.e(TAG, "文件过大: $fileName, 大小: ${fileSize / 1024 / 1024}MB") + withContext(Dispatchers.Main) { + ToastUtils.show("文件 $fileName 超过500MB限制") + } + continue + } + + // 创建上传任务 + val taskId = UUID.randomUUID().toString() + val task = UploadTaskEntity( + id = taskId, + fileName = fileName, + fileType = fileType, // 1-视频,2-图片 + fileSize = fileSize, + filePath = uri.toString(), // 使用 Uri 字符串作为路径 + scenicId = scenicId, + folderId = 0, // 自定义素材使用 folderId = 0 + status = UploadStatus.PENDING, + moduleType = "posters" // 标识为海报上传 + ) + uploadTasks.add(task) + processedCount++ + + // 每处理5个文件后,检查内存并可能触发GC + if (processedCount % 5 == 0) { + val runtime = Runtime.getRuntime() + val usedMemory = runtime.totalMemory() - runtime.freeMemory() + val maxMemory = runtime.maxMemory() + val memoryUsagePercent = (usedMemory * 100.0 / maxMemory).toInt() + + if (memoryUsagePercent > 80) { + LogUtils.i(TAG, "内存使用率较高: $memoryUsagePercent%,触发GC") + System.gc() + delay(100) // 给GC一点时间 + } + } + } catch (e: OutOfMemoryError) { + LogUtils.e(TAG, "内存溢出,无法处理文件: ${e.message}") + System.gc() // 触发垃圾回收 + delay(200) // 等待GC完成 + // 继续处理下一个文件,不中断整个流程 + } catch (e: Exception) { + LogUtils.e(TAG, "创建上传任务失败: ${e.message}") + // 继续处理下一个文件 + } + } + + if (uploadTasks.isEmpty()) { + withContext(Dispatchers.Main) { + ToastUtils.show("没有有效的文件可上传") + } + return@launch + } + + // 添加上传任务到队列 + uploadManager.addTasksToQueue(uploadTasks) + + // 记录当前批次的任务ID,用于跟踪总进度 + val taskIds = uploadTasks.map { it.id }.toSet() + withContext(Dispatchers.Main) { + _currentBatchTaskIds.value = taskIds + _uploadProgressInfo.value = UploadProgressInfo( + totalCount = uploadTasks.size, + completedCount = 0, + currentFileName = uploadTasks.firstOrNull()?.fileName, + currentProgress = 0 + ) + _showUploadProgressDialog.value = true + } + + // 启动上传完成监听(如果还没有启动) + startUploadProgressMonitoring() + + } catch (e: OutOfMemoryError) { + LogUtils.e(TAG, "处理文件上传内存溢出: ${e.message}") + System.gc() // 触发垃圾回收 + withContext(Dispatchers.Main) { + ToastUtils.show("内存不足,请减少文件数量") + } + } catch (e: Exception) { + LogUtils.e(TAG, "处理文件上传失败: ${e.message}") + withContext(Dispatchers.Main) { + ToastUtils.show("处理文件失败: ${e.message}") + } + } + } + } + + /** + * 解析文件信息(参考 TaskAddViewModel.resolveFileMeta) + * @param uri 文件 Uri + * @return Pair<文件名, 文件大小>,如果解析失败返回 null + */ + private fun resolveFileMeta(uri: Uri): Pair? { + return try { + when (uri.scheme) { + ContentResolver.SCHEME_CONTENT -> { + val resolver = context.contentResolver + var name = "unknown" + var size = -1L + var cursor: Cursor? = null + try { + cursor = resolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null + ) + cursor?.takeIf { it.moveToFirst() }?.let { c -> + val nameIndex = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && !c.isNull(nameIndex)) { + name = c.getString(nameIndex) + } + val sizeIndex = c.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex >= 0 && !c.isNull(sizeIndex)) { + size = c.getLong(sizeIndex) + } + } + } finally { + cursor?.close() + } + if (size <= 0L) { + runCatching { + resolver.openAssetFileDescriptor(uri, "r")?.use { it.length } + }.getOrNull()?.let { if (it > 0) size = it } + } + name to size.coerceAtLeast(0L) + } + + ContentResolver.SCHEME_FILE, null, "" -> { + // 处理file://或纯路径 + val path = uri.path ?: uri.toString().removePrefix("file://") + val file = File(path) + if (file.exists()) { + file.name to file.length() + } else { + // 如果文件不存在,尝试从URI字符串中提取文件名 + val fileName = path.substringAfterLast("/", "unknown") + fileName to 0L + } + } + + else -> { + // 尝试解析为文件路径 + val path = uri.path ?: uri.toString() + val file = File(path) + if (file.exists()) { + file.name to file.length() + } else { + // 从URI中提取文件名 + val fileName = path.substringAfterLast("/", "unknown") + fileName to 0L + } + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析文件信息失败: ${e.message}") + null + } + } + + // 已处理的上传任务ID集合,避免重复处理(限制大小,避免内存泄漏) + private val processedUploadTaskIds = object : LinkedHashSet() { + override fun add(element: String): Boolean { + // 限制集合大小,保留最近1000个任务ID + if (size >= 1000) { + val iterator = this.iterator() + if (iterator.hasNext()) { + iterator.next() + iterator.remove() + } + } + return super.add(element) + } + } + + // 已处理的音频URL集合,避免重复添加(限制大小,避免内存泄漏) + private val processedAudioUrls = object : LinkedHashSet() { + override fun add(element: String): Boolean { + // 限制集合大小,保留最近100个URL + if (size >= 100) { + val iterator = this.iterator() + if (iterator.hasNext()) { + iterator.next() + iterator.remove() + } + } + return super.add(element) + } + } + + // 已处理的海报URL集合,避免重复添加(限制大小,避免内存泄漏) + private val processedPosterUrls = object : LinkedHashSet() { + override fun add(element: String): Boolean { + // 限制集合大小,保留最近100个URL + if (size >= 100) { + val iterator = this.iterator() + if (iterator.hasNext()) { + iterator.next() + iterator.remove() + } + } + return super.add(element) + } + } + private var uploadMonitoringJob: kotlinx.coroutines.Job? = null + private var uploadCompletedMonitoringJob: kotlinx.coroutines.Job? = null + + // 用于同步处理上传完成事件,避免并发问题 + private val uploadCompletedMutex = Mutex() + + // 记录上一次处理的已完成任务ID集合,用于检测新完成的任务 + private var lastProcessedCompletedTaskIds = mutableSetOf() + + /** + * 启动上传进度监听(单例模式,避免重复监听) + * 监听上传进度和完成状态,更新海报列表 + */ + private fun startUploadProgressMonitoring() { + if (uploadMonitoringJob?.isActive == true) { + return // 已经在监听中 + } + + uploadMonitoringJob = viewModelScope.launch(Dispatchers.IO) { + try { + // 监听上传任务进度 + uploadManager.uploadTasks.collect { uploadTasks -> + // 更新海报列表中正在上传的项目进度 + val updatedPosterList = _posterList.value.map { poster -> + if (poster.isUploading && poster.uploadTaskId != null) { + val task = uploadTasks.find { it.id == poster.uploadTaskId } + if (task != null) { + poster.copy(uploadProgress = task.progress) + } else { + poster + } + } else { + poster + } + } + + // 更新上传进度对话框信息 + val currentBatchIds = _currentBatchTaskIds.value + if (currentBatchIds.isNotEmpty()) { + val currentBatchTasks = uploadTasks.filter { it.id in currentBatchIds } + val currentTask = currentBatchTasks.firstOrNull { + it.status == UploadStatus.UPLOADING || it.status == UploadStatus.PENDING + } + // 计算已完成数量:包括已完成的任务和已完成但还在 uploadTasks 中的任务 + val completedInUploadTasks = currentBatchTasks.count { it.status == UploadStatus.COMPLETED } + // 还需要从 completedTasks 中获取已完成的数量 + val allCompletedTasks = uploadManager.completedTasks.value + val completedInCompletedTasks = allCompletedTasks.count { + it.id in currentBatchIds && (it.moduleType == "posters" || it.moduleType == "bgm") + } + val completedCount = completedInUploadTasks + completedInCompletedTasks + + withContext(Dispatchers.Main) { + _posterList.value = updatedPosterList + + // 更新上传进度对话框(保留已有的 totalCount,只更新其他字段) + if (_showUploadProgressDialog.value) { + val currentProgressInfo = _uploadProgressInfo.value + _uploadProgressInfo.value = currentProgressInfo.copy( + completedCount = completedCount, + currentFileName = currentTask?.fileName ?: currentProgressInfo.currentFileName, + currentProgress = currentTask?.progress ?: currentProgressInfo.currentProgress + ) + } + } + } else { + withContext(Dispatchers.Main) { + _posterList.value = updatedPosterList + } + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "监听上传进度异常: ${e.message}") + } + } + + // 监听上传完成(单例模式,避免重复监听) + if (uploadCompletedMonitoringJob?.isActive == true) { + return // 已经在监听中 + } + + uploadCompletedMonitoringJob = viewModelScope.launch(Dispatchers.IO) { + try { + // 直接监听上传成功事件,而不是监听 completedTasks(避免重复处理历史任务) + uploadManager.uploadSuccessEvent.collect { completedTask -> + LogUtils.d(TAG, "收到上传成功事件: taskId=${completedTask.id}, moduleType=${completedTask.moduleType}, fileUrl=${completedTask.fileUrl}") + + // 只处理音频和海报上传 + if (completedTask.moduleType != "posters" && completedTask.moduleType != "bgm") { + return@collect + } + + // 验证任务状态和URL + if (completedTask.status != UploadStatus.COMPLETED || + completedTask.fileUrl == null || + completedTask.fileUrl.isNullOrBlank()) { + LogUtils.w(TAG, "上传成功事件任务状态无效,跳过: id=${completedTask.id}, status=${completedTask.status}, fileUrl=${completedTask.fileUrl}") + return@collect + } + + // 检查是否已处理过 + if (processedUploadTaskIds.contains(completedTask.id)) { + LogUtils.d(TAG, "任务已处理过,跳过: id=${completedTask.id}") + return@collect + } + + // 标记为已处理 + processedUploadTaskIds.add(completedTask.id) + + // 使用 Mutex 确保同一时间只有一个处理流程在执行 + uploadCompletedMutex.withLock { + withContext(Dispatchers.Main) { + if (completedTask.moduleType == "bgm") { + // 处理音频上传成功 + handleAudioUploadSuccess(completedTask) + } else if (completedTask.moduleType == "posters") { + // 处理海报上传成功 + handlePosterUploadSuccess(completedTask) + } + + // 更新上传进度并检查是否全部完成 + updateUploadProgressAndCheckCompletion() + } + } + } + + // 保留原有的 completedTasks 监听作为备用(但不再处理,避免重复) + uploadManager.completedTasks.collect { completedTasks -> + LogUtils.d(TAG, "收到 completedTasks 更新: 总数=${completedTasks.size}, bgm任务=${completedTasks.filter { it.moduleType == "bgm" }.size}, 海报任务=${completedTasks.filter { it.moduleType == "posters" }.size}") + + // 找出新完成的任务(不在上一次处理的集合中) + val currentCompletedTaskIds = completedTasks.map { it.id }.toSet() + val newCompletedTaskIds = currentCompletedTaskIds - lastProcessedCompletedTaskIds + + LogUtils.d(TAG, "上一次处理的已完成任务数量: ${lastProcessedCompletedTaskIds.size}, 当前已完成任务数量: ${currentCompletedTaskIds.size}, 新完成的任务数量: ${newCompletedTaskIds.size}") + + // 只处理新完成的任务 + val newCompletedTasks = completedTasks.filter { task -> + val isNewTask = task.id in newCompletedTaskIds + val isCompleted = task.status == UploadStatus.COMPLETED + val hasFileUrl = task.fileUrl != null && !task.fileUrl.isNullOrBlank() + val notProcessed = !processedUploadTaskIds.contains(task.id) + val isRelevantModule = task.moduleType == "posters" || task.moduleType == "bgm" + + val shouldProcess = isNewTask && isCompleted && hasFileUrl && notProcessed && isRelevantModule + if (!shouldProcess && isRelevantModule && hasFileUrl) { + if (!isNewTask) { + LogUtils.d(TAG, "跳过任务(非新任务): id=${task.id}") + } else if (!isCompleted) { + LogUtils.d(TAG, "跳过任务(未完成): id=${task.id}, status=${task.status}") + } else if (!hasFileUrl) { + LogUtils.d(TAG, "跳过任务(无URL): id=${task.id}") + } else if (!notProcessed) { + LogUtils.d(TAG, "跳过任务(已处理): id=${task.id}") + } + } + shouldProcess + } + + // 更新上一次处理的已完成任务ID集合 + lastProcessedCompletedTaskIds = currentCompletedTaskIds.toMutableSet() + + LogUtils.d(TAG, "过滤后的新完成任务: ${newCompletedTasks.size}个, 任务ID: ${newCompletedTasks.map { it.id }}") + + if (newCompletedTasks.isNotEmpty()) { + // 使用 Mutex 确保同一时间只有一个处理流程在执行 + uploadCompletedMutex.withLock { + withContext(Dispatchers.Main) { + // 分离海报和音频任务 + val posterTasks = newCompletedTasks.filter { it.moduleType == "posters" } + val bgmTasks = newCompletedTasks.filter { it.moduleType == "bgm" } + + // 处理海报上传完成 + if (posterTasks.isNotEmpty()) { + // 记录上传完成前的状态 + val beforeSelectedPosters = _selectedPosters.value.toList() + LogUtils.d(TAG, "海报上传完成 - 开始处理: 当前已选海报数量=${beforeSelectedPosters.size}, 列表=$beforeSelectedPosters") + + // 先标记任务ID为已处理,避免重复处理 + val taskIds = posterTasks.map { it.id } + processedUploadTaskIds.addAll(taskIds) + + // 更新已上传的自定义素材列表(只添加新的URL,避免重复) + val uploadedUrls = posterTasks.mapNotNull { it.fileUrl } + .filter { it.isNotBlank() } // 过滤空URL + .distinct() // 去重 + .filter { it !in processedPosterUrls } // 只处理未处理过的URL + + if (uploadedUrls.isNotEmpty()) { + // 标记URL为已处理 + processedPosterUrls.addAll(uploadedUrls) + + val currentList = _uploadedCustomMaterials.value + // 只添加不在当前列表中的新URL(双重检查) + val newUrls = uploadedUrls.filter { it !in currentList } + if (newUrls.isNotEmpty()) { + LogUtils.d(TAG, "添加新的海报URL: ${newUrls.size}个, URLs: $newUrls") + val newList = (currentList + newUrls).distinct() + // 限制最多9个新上传的海报 + val limitedList = if (newList.size > MAX_UPLOAD_COUNT) { + newList.take(MAX_UPLOAD_COUNT) // 最多保留9个 + } else { + newList + } + _uploadedCustomMaterials.value = limitedList + + // 更新显示列表:posters + all_posters + 新上传的海报的并集 + val allDisplayPosters = (_originalPosters.value + _originalAllPosters.value + _uploadedCustomMaterials.value).distinct() + val posterItems = allDisplayPosters.map { url -> + val extension = url.substringAfterLast(".").lowercase() + val isVideo = extension in listOf("mp4", "avi", "mov", "mkv", "webm") + MediaItem(url, isVideo, extension) + } + // 不保留正在上传的项目,只显示已上传成功的 + _posterList.value = posterItems + + LogUtils.d(TAG, "海报上传完成 - 处理完成: 新增数量=${newUrls.size}, 已上传总数=${limitedList.size}, 显示列表数量=${_posterList.value.size}") + } else { + LogUtils.d(TAG, "海报URL已存在于当前列表,跳过添加") + } + } else { + LogUtils.d(TAG, "所有海报URL都已处理过,跳过添加") + } + } + + // 处理音频上传完成 + if (bgmTasks.isNotEmpty()) { + // 再次验证:只处理状态为 COMPLETED 且有 fileUrl 的任务 + val validBgmTasks = bgmTasks.filter { task -> + val isValid = task.status == UploadStatus.COMPLETED && + task.fileUrl != null && + !task.fileUrl.isNullOrBlank() + if (!isValid) { + LogUtils.w(TAG, "音频任务状态无效,跳过: id=${task.id}, status=${task.status}, fileUrl=${task.fileUrl}") + } + isValid + } + + if (validBgmTasks.isEmpty()) { + LogUtils.d(TAG, "没有有效的音频上传完成任务,跳过处理") + return@withContext + } + + LogUtils.d(TAG, "音频上传完成 - 开始处理: 有效任务数量=${validBgmTasks.size}, 任务ID: ${validBgmTasks.map { it.id }}, URLs: ${validBgmTasks.mapNotNull { it.fileUrl }}") + + // 先标记任务ID为已处理,避免重复处理 + val taskIds = validBgmTasks.map { it.id } + val alreadyProcessedIds = taskIds.filter { it in processedUploadTaskIds } + if (alreadyProcessedIds.isNotEmpty()) { + LogUtils.w(TAG, "检测到已处理的任务ID,跳过: $alreadyProcessedIds") + } + processedUploadTaskIds.addAll(taskIds) + + // 更新已上传的音频列表(只添加新的URL,避免重复) + val uploadedUrls = validBgmTasks.mapNotNull { it.fileUrl } + .filter { it.isNotBlank() } // 过滤空URL + .distinct() // 去重 + + LogUtils.d(TAG, "提取的音频URL: ${uploadedUrls.size}个, URLs: $uploadedUrls") + LogUtils.d(TAG, "已处理的音频URL集合: ${processedAudioUrls.size}个") + LogUtils.d(TAG, "当前已上传的音频列表: ${_uploadedCustomBgm.value.size}个, URLs: ${_uploadedCustomBgm.value}") + + // 只处理未处理过且不在当前列表中的URL(双重检查) + val finalNewUrls = uploadedUrls.filter { url -> + val notProcessed = url !in processedAudioUrls + val notInList = url !in _uploadedCustomBgm.value + val shouldAdd = notProcessed && notInList + + if (!shouldAdd) { + if (!notProcessed) { + LogUtils.d(TAG, "音频URL已处理过,跳过: $url") + } + if (!notInList) { + LogUtils.d(TAG, "音频URL已存在于当前列表,跳过: $url") + } + } + shouldAdd + } + + if (finalNewUrls.isNotEmpty()) { + // 先标记URL为已处理(在处理前就标记,避免并发问题) + processedAudioUrls.addAll(finalNewUrls) + LogUtils.d(TAG, "标记为已处理的音频URL: ${finalNewUrls.size}个, URLs: $finalNewUrls") + + // 添加到已上传列表 + val currentList = _uploadedCustomBgm.value + LogUtils.d(TAG, "添加新的音频URL: ${finalNewUrls.size}个, URLs: $finalNewUrls") + val newList = (currentList + finalNewUrls).distinct() + // 限制最多9个新上传的音频 + val limitedList = if (newList.size > MAX_UPLOAD_COUNT) { + newList.take(MAX_UPLOAD_COUNT) // 最多保留9个 + } else { + newList + } + _uploadedCustomBgm.value = limitedList + + // 更新显示列表:bgm + all_bgm + 新上传的提示音的并集 + val allDisplayBgm = mutableListOf() + if (_originalBgm.value != null && _originalBgm.value!!.isNotEmpty()) { + allDisplayBgm.add(_originalBgm.value!!) + } + allDisplayBgm.addAll(_originalAllBgm.value) + allDisplayBgm.addAll(_uploadedCustomBgm.value) + val distinctDisplayBgm = allDisplayBgm.distinct() + + // 只有在列表真正发生变化时才更新,避免不必要的UI刷新 + val currentAudioList = _audioList.value + if (currentAudioList.size != distinctDisplayBgm.size || + currentAudioList.toSet() != distinctDisplayBgm.toSet()) { + _audioList.value = distinctDisplayBgm + LogUtils.d(TAG, "音频列表已更新: 旧数量=${currentAudioList.size}, 新数量=${distinctDisplayBgm.size}, 新增URL数量=${finalNewUrls.size}") + LogUtils.d(TAG, "显示列表详情: originalBgm=${_originalBgm.value}, allBgm数量=${_originalAllBgm.value.size}, uploadedCustomBgm数量=${_uploadedCustomBgm.value.size}") + } else { + LogUtils.d(TAG, "音频列表未变化,跳过更新") + } + + LogUtils.d(TAG, "音频上传完成 - 处理完成: 新增数量=${finalNewUrls.size}, 已上传总数=${limitedList.size}, 显示列表数量=${distinctDisplayBgm.size}") + } else { + LogUtils.d(TAG, "所有音频URL都已处理过或已存在于列表,跳过添加") + } + } + + // 检查当前批次是否全部完成 + val currentBatchIds = _currentBatchTaskIds.value + val allUploadTasks = uploadManager.uploadTasks.value + + // 立即更新总进度:每完成一个文件就更新一次 + if (currentBatchIds.isNotEmpty()) { + val completedCount = currentBatchIds.count { taskId -> + completedTasks.any { it.id == taskId } || + allUploadTasks.any { it.id == taskId && it.status == UploadStatus.COMPLETED } + } + + // 获取当前正在上传的任务信息(用于显示当前文件名和进度) + val currentBatchTasks = allUploadTasks.filter { it.id in currentBatchIds } + val currentTask = currentBatchTasks.firstOrNull { + it.status == UploadStatus.UPLOADING || it.status == UploadStatus.PENDING + } + + // 更新进度信息,保留所有字段 + val currentProgressInfo = _uploadProgressInfo.value + _uploadProgressInfo.value = currentProgressInfo.copy( + completedCount = completedCount, + currentFileName = currentTask?.fileName ?: currentProgressInfo.currentFileName, + currentProgress = currentTask?.progress ?: currentProgressInfo.currentProgress + ) + + LogUtils.d(TAG, "更新总进度: $completedCount/${currentProgressInfo.totalCount}") + } + + // 检查是否全部完成 + val allCompleted = currentBatchIds.isNotEmpty() && currentBatchIds.all { taskId -> + completedTasks.any { it.id == taskId } || + allUploadTasks.any { it.id == taskId && it.status == UploadStatus.COMPLETED } + } + + // 如果当前批次全部完成,关闭对话框 + if (allCompleted) { + _showUploadProgressDialog.value = false + _currentBatchTaskIds.value = emptySet() + val totalCount = posterTasks.size + bgmTasks.size + ToastUtils.show("${totalCount}个文件上传成功") + } + + val totalUploaded = (posterTasks.size + bgmTasks.size) + LogUtils.d(TAG, "上传成功,已保存${totalUploaded}个文件地址(海报: ${posterTasks.size}, 音频: ${bgmTasks.size})") + + // 触发垃圾回收(仅在处理大量数据后) + if (totalUploaded > 5) { + System.gc() + } + } + } + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "监听上传完成异常: ${e.message}") + } + } + } + + /** + * 处理音频上传成功 + */ + private fun handleAudioUploadSuccess(task: UploadTaskEntity) { + val fileUrl = task.fileUrl ?: return + if (fileUrl.isBlank()) { + LogUtils.w(TAG, "音频上传成功但URL为空,跳过: taskId=${task.id}") + return + } + + LogUtils.d(TAG, "处理音频上传成功: taskId=${task.id}, fileUrl=$fileUrl") + + // 检查URL是否已处理过或已存在于列表 + if (fileUrl in processedAudioUrls) { + LogUtils.d(TAG, "音频URL已处理过,跳过: $fileUrl") + return + } + + val currentList = _uploadedCustomBgm.value + if (fileUrl in currentList) { + LogUtils.d(TAG, "音频URL已存在于当前列表,跳过: $fileUrl") + return + } + + // 标记URL为已处理 + processedAudioUrls.add(fileUrl) + + // 添加到已上传列表 + val newList = (currentList + fileUrl).distinct() + val limitedList = if (newList.size > MAX_UPLOAD_COUNT) { + newList.take(MAX_UPLOAD_COUNT) + } else { + newList + } + _uploadedCustomBgm.value = limitedList + + // 更新显示列表 + val allDisplayBgm = mutableListOf() + if (_originalBgm.value != null && _originalBgm.value!!.isNotEmpty()) { + allDisplayBgm.add(_originalBgm.value!!) + } + allDisplayBgm.addAll(_originalAllBgm.value) + allDisplayBgm.addAll(_uploadedCustomBgm.value) + val distinctDisplayBgm = allDisplayBgm.distinct() + + // 只有在列表真正发生变化时才更新 + val currentAudioList = _audioList.value + if (currentAudioList.size != distinctDisplayBgm.size || + currentAudioList.toSet() != distinctDisplayBgm.toSet()) { + _audioList.value = distinctDisplayBgm + LogUtils.d(TAG, "音频列表已更新: 新增1个, 总数=${limitedList.size}, 显示列表数量=${distinctDisplayBgm.size}") + } + } + + /** + * 处理海报上传成功 + */ + private fun handlePosterUploadSuccess(task: UploadTaskEntity) { + val fileUrl = task.fileUrl ?: return + if (fileUrl.isBlank()) { + LogUtils.w(TAG, "海报上传成功但URL为空,跳过: taskId=${task.id}") + return + } + + LogUtils.d(TAG, "处理海报上传成功: taskId=${task.id}, fileUrl=$fileUrl") + + // 检查URL是否已处理过或已存在于列表 + if (fileUrl in processedPosterUrls) { + LogUtils.d(TAG, "海报URL已处理过,跳过: $fileUrl") + return + } + + val currentList = _uploadedCustomMaterials.value + if (fileUrl in currentList) { + LogUtils.d(TAG, "海报URL已存在于当前列表,跳过: $fileUrl") + return + } + + // 标记URL为已处理 + processedPosterUrls.add(fileUrl) + + // 添加到已上传列表 + val newList = (currentList + fileUrl).distinct() + val limitedList = if (newList.size > MAX_UPLOAD_COUNT) { + newList.take(MAX_UPLOAD_COUNT) + } else { + newList + } + _uploadedCustomMaterials.value = limitedList + + // 更新显示列表 + val allDisplayPosters = (_originalPosters.value + _originalAllPosters.value + _uploadedCustomMaterials.value).distinct() + val posterItems = allDisplayPosters.map { url -> + val extension = url.substringAfterLast(".").lowercase() + val isVideo = extension in listOf("mp4", "avi", "mov", "mkv", "webm") + MediaItem(url, isVideo, extension) + } + _posterList.value = posterItems + + LogUtils.d(TAG, "海报列表已更新: 新增1个, 总数=${limitedList.size}, 显示列表数量=${_posterList.value.size}") + } + + /** + * 更新上传进度并检查是否全部完成 + */ + private fun updateUploadProgressAndCheckCompletion() { + val currentBatchIds = _currentBatchTaskIds.value + if (currentBatchIds.isEmpty()) { + return + } + + // 获取所有上传任务(包括正在上传和已完成的) + val allUploadTasks = uploadManager.uploadTasks.value + val allCompletedTasks = uploadManager.completedTasks.value + + // 计算当前批次的完成数量 + val completedInUploadTasks = allUploadTasks.count { + it.id in currentBatchIds && it.status == UploadStatus.COMPLETED + } + val completedInCompletedTasks = allCompletedTasks.count { + it.id in currentBatchIds && (it.moduleType == "posters" || it.moduleType == "bgm") + } + val completedCount = completedInUploadTasks + completedInCompletedTasks + + // 获取当前正在上传的任务信息 + val currentBatchTasks = allUploadTasks.filter { it.id in currentBatchIds } + val currentTask = currentBatchTasks.firstOrNull { + it.status == UploadStatus.UPLOADING || it.status == UploadStatus.PENDING + } + + // 更新进度信息 + val currentProgressInfo = _uploadProgressInfo.value + _uploadProgressInfo.value = currentProgressInfo.copy( + completedCount = completedCount, + currentFileName = currentTask?.fileName ?: currentProgressInfo.currentFileName, + currentProgress = currentTask?.progress ?: currentProgressInfo.currentProgress + ) + + LogUtils.d(TAG, "更新上传进度: $completedCount/${currentProgressInfo.totalCount}") + + // 检查是否全部完成 + val allCompleted = currentBatchIds.all { taskId -> + allCompletedTasks.any { it.id == taskId && (it.moduleType == "posters" || it.moduleType == "bgm") } || + allUploadTasks.any { it.id == taskId && it.status == UploadStatus.COMPLETED } + } + + if (allCompleted) { + _showUploadProgressDialog.value = false + _currentBatchTaskIds.value = emptySet() + val totalCount = currentBatchIds.size + ToastUtils.show("${totalCount}个文件上传成功") + LogUtils.d(TAG, "所有文件上传完成,关闭对话框") + } + } + + /** + * 关闭上传进度对话框 + */ + fun closeUploadProgressDialog() { + _showUploadProgressDialog.value = false + } + + /** + * 保存配置 + */ + fun saveConfig() { + viewModelScope.launch { + // 先保存到本地 + // 保存客服电话(负责人电话) + appStoreDataSource.saveServicePhone(_servicePhone.value) + + // 保存首页客服电话 + appStoreDataSource.saveHomepageServicePhone(_homepageServicePhone.value) + + // 保存首页微信扫码隐私取片开关状态 + appStoreDataSource.saveShowWechatScanPrivacy(_showWechatScanPrivacy.value) + appStoreDataSource.saveHomePageLiveEnabled(_homePageLiveEnabled.value) + appStoreDataSource.saveHomeNewStyleEnabled(_homeNewStyleEnabled.value) + + // 保存海报列表 + // 注意:_selectedPosters 只包含用户手动选择的海报(最多5个) + // _uploadedCustomMaterials 是新上传的海报(最多9个) + val selectedPostersOnly = _selectedPosters.value + + // 计算 all_posters: 所有在显示列表的海报(posters + all_posters + 新上传的海报的并集) + val allPostersList = (_originalPosters.value + _originalAllPosters.value + _uploadedCustomMaterials.value).distinct() + + // 保存到本地:只保存用户手动选择的海报 + LogUtils.d(TAG, "保存配置 - 保存到本地: 用户手动选择的海报数量=${selectedPostersOnly.size}, 列表=$selectedPostersOnly") + LogUtils.d(TAG, "保存配置 - all_posters: 数量=${allPostersList.size}, 列表=$allPostersList") + appStoreDataSource.savePosterList(selectedPostersOnly) + + // 保存提示音 + _selectedAudio.value?.let { + appStoreDataSource.saveBgm(it) + } + + // 计算 all_bgm: 所有在显示列表的提示音(bgm + all_bgm + 新上传的提示音的并集) + val allBgmList = mutableListOf() + if (_originalBgm.value != null && _originalBgm.value!!.isNotEmpty()) { + allBgmList.add(_originalBgm.value!!) + } + allBgmList.addAll(_originalAllBgm.value) + allBgmList.addAll(_uploadedCustomBgm.value) + val allBgmListDistinct = allBgmList.distinct() + LogUtils.d(TAG, "保存配置 - all_bgm: 数量=${allBgmListDistinct.size}, 列表=$allBgmListDistinct") + + // 调用接口保存配置 + val request = SetConfigRequest( + phone = _servicePhone.value, + posters = selectedPostersOnly, // 已选择的海报 + allPosters = allPostersList, // 所有在显示列表的海报 + bgm = _selectedAudio.value ?: "", // 选择的提示音 + allBgm = allBgmListDistinct, // 所有在显示列表的提示音 + servicePhone = _homepageServicePhone.value.ifEmpty { null }, // 首页客服电话 + isShowQRCode = _showWechatScanPrivacy.value // 是否显示隐私取片二维码 + ) + + handleResultWithData( + flow = networkRepository.setConfig(request).asResult(), + showToast = true, + onData = { + ToastUtils.show("保存成功") + // 保存成功后,更新 _selectedPosters 为用户手动选择保存的值 + // 注意:这里只保存用户手动选择的海报,不包含新上传的(新上传的会在接口返回的 all_posters 中) + // _selectedPosters 已经在 togglePosterSelection 中更新,这里不需要再次更新 + // 清空本次上传的海报列表和提示音列表(已保存到服务器) + _uploadedCustomMaterials.value = emptyList() + _uploadedCustomBgm.value = emptyList() + // 重新加载配置以获取服务器确认的数据(包括 all_posters 和 all_bgm 中的新上传内容) + loadEquipmentConfig() + // 延迟返回上一页,确保配置已加载 + viewModelScope.launch { + delay(500) + navigateBack() + } + }, + onError = { msg, _ -> + LogUtils.e(TAG, "保存配置失败: $msg") + ToastUtils.show("保存失败: $msg") + } + ) + } + } + + override fun onCleared() { + super.onCleared() + // 取消上传监听任务,避免内存泄漏 + uploadMonitoringJob?.cancel() + uploadMonitoringJob = null + uploadCompletedMonitoringJob?.cancel() + uploadCompletedMonitoringJob = null + + // 清理已处理的任务ID集合(保留最近100个) + if (processedUploadTaskIds.size > 100) { + val iterator = processedUploadTaskIds.iterator() + val toRemove = processedUploadTaskIds.size - 100 + repeat(toRemove) { + if (iterator.hasNext()) { + iterator.next() + iterator.remove() + } + } + } + + // 释放媒体播放器 + mediaPlayer?.release() + mediaPlayer = null + + LogUtils.d(TAG, "ViewModel 已清理,释放资源") + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/dialog/PrivacyPolicyDialogFragment.kt b/app/src/main/java/com/yzx/kiosk/ui/dialog/PrivacyPolicyDialogFragment.kt new file mode 100644 index 0000000..be61982 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/dialog/PrivacyPolicyDialogFragment.kt @@ -0,0 +1,153 @@ +package com.yzx.kiosk.ui.dialog + +import android.graphics.Color +import android.graphics.Typeface +import android.net.Uri +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextPaint +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toDrawable +import androidx.core.net.toUri +import androidx.fragment.app.DialogFragment +import com.yzx.kiosk.R +import com.yzx.kiosk.databinding.DialogPrivacyPolicyBinding + +class PrivacyPolicyDialogFragment : DialogFragment() { + + interface Listener { + fun onAgree() + fun onExit() { /* 默认空实现,可按需覆写 */ + } + + fun onOpenUserAgreement(uri: Uri) { /* 打开用户协议 */ + } + + fun onOpenPrivacyPolicy(uri: Uri) { /* 打开隐私政策 */ + } + } + + private var _binding: DialogPrivacyPolicyBinding? = null + private val binding get() = _binding!! + + var listener: Listener? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // 无标题、透明背景 + setStyle(STYLE_NO_TITLE, 0) + isCancelable = false + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View { + _binding = DialogPrivacyPolicyBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // 中间两段“《用户协议》”“《隐私政策》”用 span + binding.tvLinks.bindPrivacySpans( + linkColor = ContextCompat.getColor(requireContext(), R.color.primary), + onUserAgreement = { + listener?.onOpenUserAgreement("https://zhi.youfuntour.com/h5/app/user-agreement".toUri()) + }, + onPrivacyPolicy = { + listener?.onOpenPrivacyPolicy("https://zhi.youfuntour.com/h5/app/privacy-policy".toUri()) + } + ) + + // 左右按钮 + binding.btnExit.setOnClickListener { + listener?.onExit() + // 如果需要这里直接退出应用:requireActivity().finish() + dismissAllowingStateLoss() + } + binding.btnAgree.setOnClickListener { + listener?.onAgree() + dismissAllowingStateLoss() + } + + // 拦截返回键,不让取消 + dialog?.setOnKeyListener { _, keyCode, event -> + keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP + } + } + + override fun onStart() { + super.onStart() + dialog?.window?.apply { + setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + // 宽度 90% 屏幕 + val lp = attributes + lp.width = (resources.displayMetrics.widthPixels * 0.9f).toInt() + attributes = lp + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setDimAmount(0.5f) + } + dialog?.setCanceledOnTouchOutside(false) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + fun newInstance(): PrivacyPolicyDialogFragment = PrivacyPolicyDialogFragment() + fun show( + fm: androidx.fragment.app.FragmentManager, + listener: Listener? = null + ): PrivacyPolicyDialogFragment { + val d = newInstance() + d.listener = listener + d.show(fm, "PrivacyPolicyDialog") + return d + } + } +} + +/** 让 TextView 中的两个标题用 ClickableSpan */ +private fun TextView.bindPrivacySpans( + onUserAgreement: () -> Unit, + onPrivacyPolicy: () -> Unit, + linkColor: Int +) { + val userText = "《用户协议》" + val privacyText = "《隐私政策》" + val full = "在您使用本应用之前,请您务必审慎阅读、充分理解“用户协议”和“隐私政策”各条款内容。详细资料请阅读:$userText 和 $privacyText。" + + val ssb = SpannableStringBuilder(full) + + fun span(run: () -> Unit) = object : ClickableSpan() { + override fun onClick(widget: View) = run() + override fun updateDrawState(ds: TextPaint) { + ds.color = linkColor + ds.isUnderlineText = false + ds.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + } + + val uStart = full.indexOf(userText) + val uEnd = uStart + userText.length + val pStart = full.indexOf(privacyText) + val pEnd = pStart + privacyText.length + + ssb.setSpan(span(onUserAgreement), uStart, uEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + ssb.setSpan(span(onPrivacyPolicy), pStart, pEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + + text = ssb + movementMethod = LinkMovementMethod.getInstance() + highlightColor = Color.TRANSPARENT +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt new file mode 100644 index 0000000..d145f6c --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt @@ -0,0 +1,549 @@ +package com.yzx.kiosk.ui.face.view + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import coil.compose.SubcomposeAsyncImageScope +import com.yzx.kiosk.R +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.network.model.response.FaceSearchResult +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionResultViewModel +import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog +import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage +import com.yzx.kiosk.utils.QrCodeUtils +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun FaceRecognitionResultScreen( + results: List = emptyList(), + viewModel: FaceRecognitionResultViewModel = hiltViewModel() +) { + // 如果传入了results列表,初始化图片列表 + LaunchedEffect(results) { + if (results.isNotEmpty()) { + viewModel.initPhotoList(results) + } + } + + val photoList by viewModel.photoList.collectAsState() + val selectedPhotos by viewModel.selectedPhotos.collectAsState() + val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() + val totalPrice by viewModel.totalPrice.collectAsState() + + val selectedCount = selectedPhotos.size + val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size + + // 预览弹框状态 + var showPreviewDialog by remember { mutableStateOf(false) } + var previewInitialIndex by remember { mutableIntStateOf(0) } + + // 支付弹框状态 + var showPayDialog by remember { mutableStateOf(false) } + val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState() + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBar( + backgroundColor = Color.White, + title = "人脸识别结果", + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = false + ) + FaceBarNoStatusBarPadding( + title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理" + ) + } + }, + backgroundColor = Color(0xFFF5F5F5) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .background(color = Color.White) + .padding(bottom = 12.dp) + ) { + // 顶部选择信息和支付按钮 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "已选择", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = selectedCount.toString(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0073FF) + ) + Text( + text = "张", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = "打印${pricePerPhoto}元/张", + fontSize = 21.sp, + color = Color(0xFF000000) + ) + } + + Button( + onClick = { + viewModel.getPayUrl { url -> + showPayDialog = true + } + }, + modifier = Modifier + .height(72.dp) + .widthIn(min = 180.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)), + enabled = selectedCount > 0 + ) { + Text( + text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", + fontSize = 24.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + } + + // 全选行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { viewModel.toggleSelectAll() }, + verticalAlignment = Alignment.CenterVertically + ) { + // 复选框 + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (isAllSelected) Color(0xFF0073FF) else Color.White) + .border( + width = 2.dp, + color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC), + shape = RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center + ) { + if (isAllSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(18.dp)) + + Text( + text = "全选", + fontSize = 24.sp, + color = Color.Black + ) + Text( + text = "(共${photoList.size}张)", + fontSize = 24.sp, + color = Color.Black + ) + } + + // 图片瀑布流列表 + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 22.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp), + verticalItemSpacing = 18.dp + ) { + itemsIndexed(photoList) { index, photo -> + val isSelected = selectedPhotos.contains(photo.url) + val imageLoadStates by viewModel.imageLoadStates.collectAsState() + val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState() + + FaceRecognitionPhotoItem( + photoUrl = photo.url, + aspectRatio = photo.aspectRatio, + isSelected = isSelected, + loadState = loadState, + onToggleSelection = { viewModel.togglePhotoSelection(photo.url) }, + onPreviewClick = { + // 计算在已选列表中的索引 + val selectedList = photoList.filter { selectedPhotos.contains(it.url) } + val selectedIndex = selectedList.indexOfFirst { it.url == photo.url } + if (selectedIndex >= 0) { + previewInitialIndex = selectedIndex + showPreviewDialog = true + } + }, + onLoadError = { viewModel.handleImageLoadError(photo.url) }, + onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) }, + onRetryClick = { viewModel.retryLoadImage(photo.url) } + ) + } + } + } + } + + // 预览弹框 - 仅预览已选择的图片 + val selectedPhotoUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url } + if (showPreviewDialog && selectedPhotoUrls.isNotEmpty()) { + PhotoPreviewDialog( + photoList = selectedPhotoUrls, + initialIndex = previewInitialIndex, + onDismiss = { showPreviewDialog = false } + ) + } + + // 支付弹框 + if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { + val selectedUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url } + WechatPayDialog( + qrCodeUrl = payQrCodeUrl!!, + totalPrice = totalPrice, + onDismiss = { showPayDialog = false }, + onPaySuccess = { + showPayDialog = false + // 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面 + // 跳转逻辑由 FaceRecognitionResultViewModel 中的 WebSocket 监听处理 + } + ) + } +} + +@Composable +fun FaceRecognitionPhotoItem( + photoUrl: String, + aspectRatio: Float, + isSelected: Boolean, + loadState: FaceRecognitionResultViewModel.ImageLoadState = FaceRecognitionResultViewModel.ImageLoadState(), + onToggleSelection: () -> Unit, + onPreviewClick: () -> Unit, + onLoadError: () -> Unit = {}, + onLoadSuccess: () -> Unit = {}, + onRetryClick: () -> Unit = {} +) { + // 确保 aspectRatio 是有效值 + val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) { + aspectRatio + } else { + 1f // 默认1:1 + } + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(validAspectRatio) + .clip(RoundedCornerShape(18.dp)) + .clickable { onToggleSelection() } + ) { + // 图片 - 按原比例显示 + var lastErrorState by remember { mutableStateOf(false) } + var lastSuccessState by remember { mutableStateOf(false) } + + SubcomposeAsyncImage( + model = photoUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) { + when (val state = painter.state) { + is coil.compose.AsyncImagePainter.State.Loading -> { + // 加载中显示占位符 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color(0xFF2196F3) + ) + } + } + is coil.compose.AsyncImagePainter.State.Error -> { + // 错误时处理重试逻辑(只处理一次) + LaunchedEffect(state) { + if (!lastErrorState) { + lastErrorState = true + lastSuccessState = false + onLoadError() + } + } + + // 如果已经重试3次,显示加载失败,支持点击重试 + if (loadState.isFailed) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)) + .clickable { + lastErrorState = false + onRetryClick() + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "加载失败", + fontSize = 12.sp, + color = Color(0xFF999999) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "点击重试", + fontSize = 10.sp, + color = Color(0xFF2196F3) + ) + } + } + } else { + // 正在重试中,显示加载指示器 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color(0xFF2196F3) + ) + } + } + } + else -> { + // 加载成功 + LaunchedEffect(state) { + if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) { + lastSuccessState = true + lastErrorState = false + onLoadSuccess() + } + } + SubcomposeAsyncImageContent() + } + } + } + + // 选中遮罩 + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent + ) + ) + + // 右上角复选框 + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (isSelected) Color(0xFF0073FF) else Color.Black.copy(alpha = 0.5f)) + .border( + width = 2.dp, + color = if (isSelected) Color(0xFF0073FF) else Color(0xFF666666), + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + // 点击预览文字(选中时显示)- 点击打开预览弹框 + if (isSelected) { + Text( + text = "点击预览", + fontSize = 24.sp, + color = Color.White, + modifier = Modifier + .align(Alignment.Center) + .clickable { onPreviewClick() } + ) + } + } +} + +/** + * 微信支付弹框 + */ +@Composable +fun WechatPayDialog( + qrCodeUrl: String, + totalPrice: String, + onDismiss: () -> Unit, + onPaySuccess: () -> Unit +) { + // 使用接口返回的URL生成支付二维码 + val qrCodeBitmap = remember(qrCodeUrl) { + QrCodeUtils.generateStyledQrBitmap( + content = qrCodeUrl, + size = 600, + qrColor = android.graphics.Color.BLACK, + bgColor = android.graphics.Color.WHITE, + cornerRadius = 0f + ) + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.85f) + .clip(RoundedCornerShape(18.dp)) + .background(Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 标题和关闭按钮 + Box( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "微信支付", + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.align(Alignment.Center) + ) + + // 关闭按钮 + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF999999)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "关闭", + tint = Color(0xFFFFFFFF), + modifier = Modifier.size(32.dp) + ) + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + // 二维码 + Box( + modifier = Modifier + .size(220.dp) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = qrCodeBitmap.asImageBitmap(), + contentDescription = "支付二维码", + modifier = Modifier.fillMaxSize() + ) + } + + Spacer(modifier = Modifier.height(22.dp)) + + // 提示文字 + Text( + text = "请使用微信扫描二维码支付", + fontSize = 21.sp, + color = Color(0xFF000000) + ) + } + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt new file mode 100644 index 0000000..978efd9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt @@ -0,0 +1,358 @@ +package com.yzx.kiosk.ui.face.view + +import android.Manifest +import android.graphics.Bitmap +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.camera.core.* +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel +import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus +import java.util.concurrent.Executor +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +fun FaceRecognitionScreen( + viewModel: FaceRecognitionViewModel = hiltViewModel() +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + val countdown by viewModel.countdown.collectAsState() + val captureCountdown by viewModel.captureCountdown.collectAsState() + val recognitionStatus by viewModel.recognitionStatus.collectAsState() + val capturedImage by viewModel.capturedImage.collectAsState() + val isRecognizing by viewModel.isRecognizing.collectAsState() + + // 相机权限 + val permissionsState = rememberMultiplePermissionsState( + permissions = listOf(Manifest.permission.CAMERA) + ) + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBar( + backgroundColor = Color.White, + title = "人脸识别", + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = true + ) + FaceBarNoStatusBarPadding( + title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理" + ) + } + }, + backgroundColor = Color(0xFFF5F5F5) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 摄像头预览区域 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 36.dp) + .aspectRatio(0.92f), + shape = RoundedCornerShape(36.dp), + colors = CardDefaults.cardColors(containerColor = Color.Black) + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (permissionsState.allPermissionsGranted) { + // 显示摄像头预览 + CameraPreview( + onImageCaptureReady = { imageCapture -> + viewModel.setImageCapture(imageCapture) + }, + modifier = Modifier.fillMaxSize() + ) + + // 识别状态横幅(识别中或失败时显示) + if (isRecognizing || recognitionStatus == RecognitionStatus.FAILED) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background( + when (recognitionStatus) { + RecognitionStatus.RECOGNIZING -> + Color(0xFF22C55E).copy(alpha = 1.0f) + + RecognitionStatus.FAILED -> + Color(0xFFFF7B00) + + else -> Color.Transparent + } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = when (recognitionStatus) { + RecognitionStatus.RECOGNIZING -> "识别中..." + RecognitionStatus.FAILED -> "识别失败,暂未找到您的照片" + else -> "" + }, + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + } + + // 拍照倒计时显示 + if (captureCountdown > 0 && !isRecognizing && recognitionStatus != RecognitionStatus.FAILED) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = captureCountdown.toString(), + fontSize = 108.sp, + color = Color.White.copy(alpha = 0.7f), + fontWeight = FontWeight.Bold + ) + } + } + + // 右上角显示已拍照片缩略图(识别失败时不显示) + if (recognitionStatus != RecognitionStatus.FAILED) { + capturedImage?.let { bitmap -> + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 72.dp) + .width(144.dp) + .height(174.dp) + .clip(RoundedCornerShape(36.dp)) + .background(Color.White, RoundedCornerShape(36.dp)) + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "已拍照片", + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .clip(RoundedCornerShape(36.dp)), + contentScale = ContentScale.Crop + ) + } + } + } + } else { + // 权限未授予 + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "人脸识别需要相机权限", + color = Color.White, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { permissionsState.launchMultiplePermissionRequest() } + ) { + Text("授予权限") + } + } + } + } + // 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照") + if (!isRecognizing) { + Button( + onClick = { + if (recognitionStatus == RecognitionStatus.FAILED) { + viewModel.resetState() + viewModel.startCamera() + } else { + // 如果正在倒计时,先取消倒计时 + if (captureCountdown > 0) { + viewModel.cancelAutoCapture() + } + viewModel.takePhoto() + } + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(36.dp) + .height(72.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)) + ) { + Text( + text = if (recognitionStatus == RecognitionStatus.FAILED) "重新拍照" else "拍照", + fontSize = 24.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + // 倒计时提示 + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontSize = 27.sp, color = Color(0xFF0073FF))) { + append("${countdown}") + } + append("秒后自动返回") + }, + fontWeight = FontWeight.Bold, + fontSize = 27.sp, + color = Color(0xFF000000) + ) + + Spacer(modifier = Modifier.height(48.dp)) + + // 状态提示 + Text( + text = when { + isRecognizing -> "正在人脸识别中" + recognitionStatus == RecognitionStatus.FAILED -> "识别失败,暂未找到您的照片" + else -> "正在人脸识别中" + }, + fontSize = 27.sp, + color = Color.Black, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理", + fontSize = 12.sp, + color = Color(0xFF000000) + ) + Spacer(modifier = Modifier.height(48.dp)) + + // 隐私条款和用户协议 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { viewModel.privatePolicy() }) { + Text( + text = "隐私条款", + fontSize = 18.sp, + color = Color(0xFF0073FF) + ) + } + Spacer(modifier = Modifier.width(36.dp)) + TextButton(onClick = {viewModel.userAgreement()}) { + Text( + text = "用户协议", + fontSize = 18.sp, + color = Color(0xFF0073FF) + ) + } + } + } + } + + // 初始化相机和自动拍照 + LaunchedEffect(permissionsState.allPermissionsGranted) { + if (permissionsState.allPermissionsGranted) { + // 相机已准备好,开始5秒倒计时自动拍照 + viewModel.startCamera() + } + } +} + +@Composable +fun CameraPreview( + onImageCaptureReady: (ImageCapture) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + AndroidView( + factory = { ctx -> + val previewView = PreviewView(ctx) + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + + // 创建预览 + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + // 创建图像捕获 + val imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + + // 通知ViewModel ImageCapture已准备好 + onImageCaptureReady(imageCapture) + + // 绑定到生命周期 + val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + imageCapture + ) + } catch (e: Exception) { + e.printStackTrace() + } + }, ContextCompat.getMainExecutor(ctx)) + + previewView + }, + modifier = modifier + ) +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt new file mode 100644 index 0000000..57088f1 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt @@ -0,0 +1,515 @@ +package com.yzx.kiosk.ui.face.viewmodel + +import android.graphics.ImageDecoder +import android.os.Build +import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Size +import com.yzx.kiosk.App +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.network.model.request.GetPayUrlRequest +import com.yzx.kiosk.network.model.request.VerifyResultRequest +import com.yzx.kiosk.network.model.response.FaceSearchResult +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.ui.upload.viewmodel.FileMapData +import com.yzx.kiosk.ui.upload.viewmodel.PhotoData +import com.yzx.kiosk.utils.ToastUtils +import com.yzx.kiosk.websocket.WebSocketService +import com.yzx.kiosk.websocket.UploadPhotoEvent +import com.google.gson.Gson +import com.yzx.kiosk.datastore.AppStoreDataSource +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import com.yzx.kiosk.utils.LogUtils +import javax.inject.Inject + +@HiltViewModel +class FaceRecognitionResultViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val netWorkRepository: NetWorkRepository, + private val webSocketService: WebSocketService, + private val appStoreDataSource: AppStoreDataSource, +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + // 原始结果列表(用于获取图片id) + private var originalResults: List = emptyList() + + // URL到ID的映射 + private val urlToIdMap = mutableMapOf() + + // 图片列表 + private val _photoList = MutableStateFlow>(emptyList()) + val photoList: StateFlow> = _photoList.asStateFlow() + + // 已选中的图片URL(用于UI显示) + private val _selectedPhotos = MutableStateFlow>(emptySet()) + val selectedPhotos: StateFlow> = _selectedPhotos.asStateFlow() + + // 单价 + private val _pricePerPhoto = MutableStateFlow("--") + val pricePerPhoto: StateFlow = _pricePerPhoto.asStateFlow() + + // 总价 + private val _totalPrice = MutableStateFlow("--") + val totalPrice: StateFlow = _totalPrice.asStateFlow() + + // 支付二维码URL + private val _payQrCodeUrl = MutableStateFlow(null) + val payQrCodeUrl: StateFlow = _payQrCodeUrl.asStateFlow() + + // 图片加载状态:URL -> (重试次数, 是否加载失败) + private val _imageLoadStates = MutableStateFlow>(emptyMap()) + val imageLoadStates: StateFlow> = _imageLoadStates.asStateFlow() + + companion object { + private const val MAX_RETRY_COUNT = 3 + } + + data class ImageLoadState( + val retryCount: Int = 0, + val isFailed: Boolean = false, + val isLoading: Boolean = false + ) + + init { + // 监听 WebSocket 支付成功事件 + webSocketService.uploadPhotoEvents + .onEach { event -> + when (event) { + is UploadPhotoEvent.PaySuccess -> { + // 支付成功,跳转到支付成功页面 + LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}") + navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds) + } + else -> { + // 其他事件不处理 + } + } + } + .launchIn(viewModelScope) + } + + /** + * 初始化图片列表(从人脸识别结果) + */ + fun initPhotoList(results: List) { + viewModelScope.launch { + // 保存原始结果列表 + originalResults = results + + // 先使用默认宽高比创建列表 + val initialPhotos = results.mapNotNull { result -> + // 使用 thumbnail_oss_url + val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl + if (imageUrl.isNullOrEmpty()) { + LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过") + null + } else { + val trimmedUrl = imageUrl.trim() + // 建立URL到ID的映射 + urlToIdMap[trimmedUrl] = result.id + PhotoData( + id = result.id, // 使用图片ID + url = trimmedUrl, + aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比 + ) + } + } + + if (initialPhotos.isEmpty()) { + LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL") + _photoList.value = emptyList() + _imageLoadStates.value = emptyMap() + return@launch + } + + _photoList.value = initialPhotos + // 初始化加载状态 + _imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() } + + // 页面初始化时调用验证接口(空列表,type: 2),获取默认价格 + verifyResult(emptyList()) + + // 并发获取所有图片的真实尺寸并更新宽高比 + val updatedPhotos = initialPhotos.mapIndexed { index, photo -> + async(Dispatchers.IO) { + try { + val aspectRatio = getImageAspectRatio(photo.url) + // 确保 aspectRatio 是有效值 + val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) { + aspectRatio + } else { + LogUtils.i("FaceRecognitionResultViewModel", "图片 ${photo.url} 的宽高比无效: $aspectRatio,使用默认值") + 1f + } + photo.copy(aspectRatio = validAspectRatio) + } catch (e: Exception) { + LogUtils.e("FaceRecognitionResultViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}") + // 获取失败,保持默认值 + photo + } + } + }.awaitAll() + + _photoList.value = updatedPhotos + LogUtils.d("FaceRecognitionResultViewModel", "图片列表初始化完成,共 ${updatedPhotos.size} 张") + } + } + + /** + * 获取图片的宽高比 + */ + private suspend fun getImageAspectRatio(imageUrl: String): Float { + return withTimeoutOrNull(10000) { // 10秒超时 + try { + val imageLoader = ImageLoader(App.instance) + val request = ImageRequest.Builder(App.instance) + .data(imageUrl) + .size(Size.ORIGINAL) + .allowHardware(false) // 禁用硬件加速,避免某些图片无法获取尺寸 + .build() + + val result = imageLoader.execute(request) + val drawable = result.drawable + + if (drawable != null) { + val width = drawable.intrinsicWidth + val height = drawable.intrinsicHeight + + if (width > 0 && height > 0) { + val aspectRatio = width.toFloat() / height.toFloat() + LogUtils.d("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸: ${width}x${height}, 宽高比: $aspectRatio") + return@withTimeoutOrNull aspectRatio + } else { + LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸无效: ${width}x${height}") + } + } else { + LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null") + } + } catch (e: Exception) { + LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}") + } + null + } ?: run { + LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时") + 1f // 超时或失败,返回默认值 + } + } + + /** + * 切换单张图片选中状态 + */ + fun togglePhotoSelection(url: String) { + val current = _selectedPhotos.value.toMutableSet() + if (current.contains(url)) { + current.remove(url) + } else { + current.add(url) + } + _selectedPhotos.value = current + + // 选择变化时调用接口获取价格 + val selectedIds = getSelectedImageIds() + verifyResult(selectedIds) + } + + /** + * 切换全选状态 + */ + fun toggleSelectAll() { + val allUrls = _photoList.value.map { it.url }.toSet() + val newSelected = if (_selectedPhotos.value.size == allUrls.size) { + // 当前全选,取消全选 + emptySet() + } else { + // 全选 + allUrls + } + _selectedPhotos.value = newSelected + + // 选择变化时调用接口获取价格 + val selectedIds = getSelectedImageIds() + verifyResult(selectedIds) + } + + /** + * 获取已选中的图片ID列表 + */ + private fun getSelectedImageIds(): List { + return _selectedPhotos.value.mapNotNull { url -> + urlToIdMap[url] + } + } + + /** + * 验证结果接口 + */ + private fun verifyResult(imageIds: List) { + viewModelScope.launch { + try { + val request = VerifyResultRequest( + type = 2, + imageId = imageIds + ) + + handleResultWithData( + flow = netWorkRepository.verifyResult(request).asResult(), + showToast = false, + onData = { response -> + // 更新单价(使用接口返回的 price_image) + val priceImage = response.priceImage + if (!priceImage.isNullOrEmpty()) { + _pricePerPhoto.value = priceImage + } else { + _pricePerPhoto.value = "--" + } + + // 更新总价(使用接口返回的 amount) + val amount = response.amount + if (!amount.isNullOrEmpty()) { + _totalPrice.value = amount + } else { + _totalPrice.value = "--" + } + + LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount") + }, + onError = { msg, _ -> + LogUtils.e("FaceRecognitionResultViewModel", "验证结果失败: $msg") + // 接口失败时,保持当前值或设置为默认值 + if (imageIds.isEmpty()) { + _pricePerPhoto.value = "--" + _totalPrice.value = "--" + } + } + ) + } catch (e: Exception) { + LogUtils.e("FaceRecognitionResultViewModel", "验证结果异常: ${e.message}") + if (imageIds.isEmpty()) { + _pricePerPhoto.value = "--" + _totalPrice.value = "--" + } + } + } + } + + /** + * 获取支付URL + */ + fun getPayUrl(onSuccess: (String) -> Unit) { + viewModelScope.launch { + val selectedIds = getSelectedImageIds() + if (selectedIds.isEmpty()) { + ToastUtils.show("请选择要打印的照片") + return@launch + } + showLoading() + try { + val request = GetPayUrlRequest( + type = 2, + imageId = selectedIds + ) + + handleResultWithData( + flow = netWorkRepository.getPayUrl(request).asResult(), + showToast = false, + onData = { response -> + val url = response.url + if (url.isNullOrEmpty()) { + ToastUtils.show("获取支付二维码失败") + } else { + _payQrCodeUrl.value = url + onSuccess(url) + } + }, + onError = { msg, _ -> + LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg") + ToastUtils.show("获取支付二维码失败") + }, + onEnd = { + hideLoading() + } + ) + } catch (e: Exception) { + LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}") + ToastUtils.show("获取支付二维码失败") + } + } + } + + /** + * 跳转到支付成功页面 + * @param orderNumber 订单号 + * @param captureType 抓拍类型 + * @param imageIds 图片ID数组(从支付成功消息中获取) + */ + private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, imageIds: List) { + viewModelScope.launch { + // 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult + val selectedResults = originalResults.filter { imageIds.contains(it.id) } + + if (selectedResults.isEmpty()) { + LogUtils.e("FaceRecognitionResultViewModel", "未找到对应的图片数据,imageIds: $imageIds") + ToastUtils.show("支付成功,但未找到对应的图片数据") + return@launch + } + + // 将 FaceSearchResult 转换为 FileMapData + val fileMapDataList = selectedResults.map { result -> + FileMapData( + id = result.id, + originalOssUrl = result.originalOssUrl, + thumbnailOssUrl = result.thumbnailOssUrl, + thumbnailLanUrl = result.thumbnailLanUrl, + originalLanUrl = result.originalLanUrl + ) + } + + // 将 FileMapData 数组序列化为 JSON 数组字符串 + val gson = Gson() + val jsonArray = gson.toJson(fileMapDataList) + val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8") + + // 构建路由参数 + val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" + val captureTypeParam = captureType?.toString() ?: "" + + // 创建 NavOptions,清除当前页面 + val navOptions = androidx.navigation.NavOptions.Builder() + .setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) + .build() + + // 跳转到支付成功页面 + val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam" + toPage(route, navOptions) + } + } + + /** + * 处理图片加载失败 + */ + fun handleImageLoadError(url: String) { + viewModelScope.launch { + val currentStates = _imageLoadStates.value.toMutableMap() + val currentState = currentStates[url] ?: ImageLoadState() + + // 如果已经在加载中,避免重复处理 + if (currentState.isLoading) { + return@launch + } + + if (currentState.retryCount < MAX_RETRY_COUNT) { + // 重试 + val newRetryCount = currentState.retryCount + 1 + LogUtils.d("FaceRecognitionResultViewModel", "图片 $url 加载失败,重试 $newRetryCount/$MAX_RETRY_COUNT") + currentStates[url] = currentState.copy( + retryCount = newRetryCount, + isLoading = true + ) + _imageLoadStates.value = currentStates + + // 延迟后重试(给 Coil 一些时间) + kotlinx.coroutines.delay(500) + + // 触发重新加载(通过更新 URL 来强制 Coil 重新加载) + val updatedList = _photoList.value.map { photo -> + if (photo.url == url || photo.url.startsWith("$url?")) { + // 添加时间戳参数来强制重新加载 + photo.copy(url = "$url?retry=${System.currentTimeMillis()}") + } else { + photo + } + } + _photoList.value = updatedList + + // 恢复原始 URL(避免 URL 被污染) + kotlinx.coroutines.delay(200) + val restoredList = _photoList.value.map { photo -> + if (photo.url.startsWith(url)) { + photo.copy(url = url) + } else { + photo + } + } + _photoList.value = restoredList + } else { + // 达到最大重试次数,标记为失败 + LogUtils.i("FaceRecognitionResultViewModel", "图片 $url 加载失败,已达到最大重试次数") + currentStates[url] = currentState.copy( + retryCount = MAX_RETRY_COUNT, + isFailed = true, + isLoading = false + ) + _imageLoadStates.value = currentStates + } + } + } + + /** + * 处理图片加载成功 + */ + fun handleImageLoadSuccess(url: String) { + viewModelScope.launch { + val currentStates = _imageLoadStates.value.toMutableMap() + val currentState = currentStates[url] + if (currentState != null && (currentState.isFailed || currentState.retryCount > 0)) { + // 重置状态 + currentStates[url] = ImageLoadState() + _imageLoadStates.value = currentStates + LogUtils.d("FaceRecognitionResultViewModel", "图片 $url 加载成功") + } + } + } + + /** + * 手动重试加载图片 + */ + fun retryLoadImage(url: String) { + viewModelScope.launch { + val currentStates = _imageLoadStates.value.toMutableMap() + // 重置状态,重新开始重试 + currentStates[url] = ImageLoadState(isLoading = true, retryCount = 0) + _imageLoadStates.value = currentStates + + LogUtils.d("FaceRecognitionResultViewModel", "手动重试加载图片: $url") + + // 触发重新加载 + val updatedList = _photoList.value.map { photo -> + if (photo.url == url || photo.url.startsWith("$url?")) { + photo.copy(url = "$url?retry=${System.currentTimeMillis()}") + } else { + photo + } + } + _photoList.value = updatedList + + // 恢复原始 URL + kotlinx.coroutines.delay(200) + val restoredList = _photoList.value.map { photo -> + if (photo.url.startsWith(url)) { + photo.copy(url = url) + } else { + photo + } + } + _photoList.value = restoredList + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt new file mode 100644 index 0000000..dd56d9b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt @@ -0,0 +1,480 @@ +package com.yzx.kiosk.ui.face.viewmodel + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import androidx.camera.core.ImageCapture +import androidx.exifinterface.media.ExifInterface +import androidx.camera.core.ImageCaptureException +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavOptions +import com.yzx.kiosk.audio.LocalAudioPlayService +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.google.gson.Gson +import com.yzx.kiosk.network.model.response.FaceSearchResponse +import com.yzx.kiosk.network.service.FaceSearchService +import com.yzx.kiosk.App +import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import java.net.URLEncoder +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.File +import java.text.SimpleDateFormat +import java.util.* +import javax.inject.Inject + +enum class RecognitionStatus { + IDLE, + RECOGNIZING, + SUCCESS, + FAILED +} + +@HiltViewModel +class FaceRecognitionViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val appStoreDataSource: AppStoreDataSource, + private val localAudioPlayService: LocalAudioPlayService +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + companion object { + private const val TAG = "FaceRecognitionViewModel" + + private const val COUNTDOWN_SECONDS = 90 + private const val AUTO_CAPTURE_COUNTDOWN = 5 + } + + // 倒计时(90秒) + private val _countdown = MutableStateFlow(COUNTDOWN_SECONDS) + val countdown: StateFlow = _countdown.asStateFlow() + + // 拍照倒计时(5秒) + private val _captureCountdown = MutableStateFlow(0) + val captureCountdown: StateFlow = _captureCountdown.asStateFlow() + + // 识别状态 + private val _recognitionStatus = MutableStateFlow(RecognitionStatus.IDLE) + val recognitionStatus: StateFlow = _recognitionStatus.asStateFlow() + + // 拍摄的照片 + private val _capturedImage = MutableStateFlow(null) + val capturedImage: StateFlow = _capturedImage.asStateFlow() + + // 是否正在识别 + private val _isRecognizing = MutableStateFlow(false) + val isRecognizing: StateFlow = _isRecognizing.asStateFlow() + + // 倒计时Job + private var countdownJob: kotlinx.coroutines.Job? = null + private var autoCaptureJob: kotlinx.coroutines.Job? = null + + // ImageCapture实例 + private var imageCapture: ImageCapture? = null + + init { + startCountdown() + } + + /** + * 设置ImageCapture实例 + */ + fun setImageCapture(imageCapture: ImageCapture) { + this.imageCapture = imageCapture + } + + /** + * 开始倒计时 + */ + private fun startCountdown() { + countdownJob?.cancel() + countdownJob = viewModelScope.launch { + while (_countdown.value > 0) { + delay(1000) + _countdown.value = _countdown.value - 1 + } + // 倒计时结束,自动返回首页 + toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME) + } + } + + /** + * 启动相机(开始5秒倒计时) + */ + fun startCamera() { + autoCaptureJob?.cancel() + _captureCountdown.value = AUTO_CAPTURE_COUNTDOWN + autoCaptureJob = viewModelScope.launch { + // 5秒倒计时 + for (i in AUTO_CAPTURE_COUNTDOWN downTo 1) { + _captureCountdown.value = i + delay(1000) + } + _captureCountdown.value = 0 + // 自动拍照 + takePhoto() + } + } + + /** + * 取消自动拍照倒计时 + */ + fun cancelAutoCapture() { + autoCaptureJob?.cancel() + _captureCountdown.value = 0 + } + + /** + * 手动拍照 + */ + fun takePhoto() { + // 如果正在自动拍照倒计时,先取消 + if (_captureCountdown.value > 0) { + cancelAutoCapture() + } + + // 如果已有照片,重置状态 + if (_capturedImage.value != null) { + _capturedImage.value = null + _recognitionStatus.value = RecognitionStatus.IDLE + _isRecognizing.value = false + } + + val capture = imageCapture ?: run { + ToastUtils.show("相机未准备好") + return + } + + viewModelScope.launch { + // 创建输出文件 + val photoFile = withContext(Dispatchers.IO) { + File.createTempFile( + "IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}", + ".jpg", + App.instance.cacheDir + ) + } + + // 创建输出选项 + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() + + // 拍照 + capture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(App.instance), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + // 加载图片为Bitmap,并处理EXIF方向 + val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath) + _capturedImage.value = bitmap + // 直接调用搜索接口 + searchFaceDirectly(photoFile) + } + + override fun onError(exception: ImageCaptureException) { + LogUtils.e(TAG, "拍照失败: ${exception.message}") + ToastUtils.show("拍照失败") + } + } + ) + } + } + + /** + * 直接调用人脸搜索接口 + */ + private fun searchFaceDirectly(photoFile: File) { + viewModelScope.launch { + _isRecognizing.value = true + _recognitionStatus.value = RecognitionStatus.RECOGNIZING + // 播放识别中音频 + localAudioPlayService.playByRoute("face_recognition_recognizing") + // 播放识别中音频 + localAudioPlayService.playByRoute("face_recognition_recognizing") + + try { + // 直接调用人脸识别接口 + searchFace(photoFile) + } catch (e: Exception) { + LogUtils.e(TAG, "处理失败: ${e.message}") + ToastUtils.show("处理失败: ${e.message}") + _isRecognizing.value = false + _recognitionStatus.value = RecognitionStatus.FAILED + } + } + } + + /** + * 搜索人脸 + */ + private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) { + try { + + val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl() + val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken() + + + // 创建MultipartBody + val requestFile = imageFile.asRequestBody("image/jpeg".toMediaType()) + val imagePart = MultipartBody.Part.createFormData("image", imageFile.name, requestFile) + + // threshold 参数:默认 0.45 + val thresholdValue = 0.45f + val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType()) + + // index_date 参数:当前日期,格式 YYYYMMDD + val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) + val indexDate = dateFormat.format(Date()) + val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType()) + + // Authorization header + val authorization = "Bearer $FACE_SEARCH_TOKEN" + + // 打印请求信息 + val requestUrl = "$FACE_SEARCH_BASE_URL/api/search" + LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========") + LogUtils.d(TAG, "URL: $requestUrl") + LogUtils.d(TAG, "Headers:") + LogUtils.d(TAG, "Authorization: $authorization") + LogUtils.d(TAG, "Parameters:") + LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)") + LogUtils.d(TAG, "threshold: $thresholdValue") + LogUtils.d(TAG, "index_date: $indexDate") + LogUtils.d(TAG, "==========================================") + + // 创建日志拦截器 + val loggingInterceptor = HttpLoggingInterceptor { message -> + LogUtils.d(TAG, message) + }.apply { + level = HttpLoggingInterceptor.Level.BODY + } + + // 创建OkHttpClient,添加日志拦截器 + val client = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + + // 创建Retrofit实例 + val retrofit = Retrofit.Builder() + .baseUrl("$FACE_SEARCH_BASE_URL/") + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val service = retrofit.create(FaceSearchService::class.java) + + // 调用接口 + val response = service.searchFace( + sn = appStoreDataSource.getBindBoxSn(), + authorization = authorization, + image = imagePart, + threshold = thresholdBody, + indexDate = null + ) + + // 打印响应信息 + LogUtils.d(TAG, "========== 人脸识别接口响应信息 ==========") + LogUtils.d(TAG, "响应状态码: ${response.code()}") + LogUtils.d(TAG, "响应消息: ${response.message()}") + if (response.isSuccessful) { + val result = response.body() + LogUtils.d(TAG, "响应成功,结果: count=${result?.count}, results.size=${result?.results?.size}") + LogUtils.d(TAG, "==========================================") + handleSearchResult(result) + } else { + LogUtils.e(TAG, "响应失败: ${response.code()}, ${response.message()}") + LogUtils.e(TAG, "错误响应体: ${response.errorBody()?.string()}") + LogUtils.d(TAG, "==========================================") + ToastUtils.show("识别失败") + withContext(Dispatchers.Main) { + _isRecognizing.value = false + _recognitionStatus.value = RecognitionStatus.FAILED + // 播放识别失败音频 + localAudioPlayService.playByRoute("face_recognition_failed") + } + } + } catch (e: Exception) { + LogUtils.e("人脸识别失败: ${e.message}") + ToastUtils.show("识别失败: ${e.message}") + withContext(Dispatchers.Main) { + _isRecognizing.value = false + _recognitionStatus.value = RecognitionStatus.FAILED + // 播放识别失败音频 + localAudioPlayService.playByRoute("face_recognition_failed") + } + } + } + + /** + * 处理搜索结果 + */ + private fun handleSearchResult(result: FaceSearchResponse?) { + viewModelScope.launch { + _isRecognizing.value = false + + if (result == null || result.count == 0 || result.results.isNullOrEmpty()) { + // 识别失败 + _recognitionStatus.value = RecognitionStatus.FAILED + // 播放识别失败音频 + localAudioPlayService.playByRoute("face_recognition_failed") + } else { + // 识别成功 + _recognitionStatus.value = RecognitionStatus.SUCCESS + ToastUtils.show("找到 ${result.count} 张照片") + + // 播放识别成功音频(在跳转前播放) + localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT) + + // 跳转到人脸识别结果页面,传递results数组,并清除人脸识别页面的回退栈 + val resultsJson = Gson().toJson(result.results) + val encodedResults = URLEncoder.encode(resultsJson, "UTF-8") + val targetRoute = "${com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT}?results=$encodedResults" + + // 创建 NavOptions,清除人脸识别页面 + val navOptions = NavOptions.Builder() + .setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION, inclusive = true) + .build() + + toPage(targetRoute, navOptions) + } + } + } + + /** + * 加载Bitmap并处理EXIF方向信息 + * 解决不同设备拍照后图片方向不正确的问题 + */ + private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? { + return try { + // 先读取EXIF方向信息 + val exif = ExifInterface(imagePath) + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + + // 加载原始Bitmap + val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null + + // 根据EXIF方向旋转Bitmap + val rotationDegrees = when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90f + ExifInterface.ORIENTATION_ROTATE_180 -> 180f + ExifInterface.ORIENTATION_ROTATE_270 -> 270f + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { + // 水平翻转 + val matrix = Matrix() + matrix.setScale(-1f, 1f) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + // 垂直翻转 + val matrix = Matrix() + matrix.setScale(1f, -1f) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + ExifInterface.ORIENTATION_TRANSPOSE -> { + // 转置(旋转90度+水平翻转) + val matrix = Matrix() + matrix.setRotate(90f) + matrix.postScale(-1f, 1f) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + // 横向(旋转270度+水平翻转) + val matrix = Matrix() + matrix.setRotate(270f) + matrix.postScale(-1f, 1f) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + else -> 0f // 不需要旋转 + } + + // 如果需要旋转 + if (rotationDegrees != 0f) { + val matrix = Matrix() + matrix.postRotate(rotationDegrees) + val rotatedBitmap = Bitmap.createBitmap( + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true + ) + // 回收原始bitmap + if (rotatedBitmap != bitmap) { + bitmap.recycle() + } + rotatedBitmap + } else { + bitmap + } + } catch (e: Exception) { + LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}") + // 如果处理失败,返回原始Bitmap + BitmapFactory.decodeFile(imagePath) + } + } + + fun privatePolicy() { + val args = mapOf( + AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html", + AgreementRoutes.AGREEMENT_NAME to "隐私条款", + ) + + toPage(AppRoutes.AGREEMENT, args) + } + fun userAgreement() { + val args = mapOf( + AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/user-agreement.html", + AgreementRoutes.AGREEMENT_NAME to "用户协议", + ) + + toPage(AppRoutes.AGREEMENT, args) + } + + /** + * 重置状态(用于重新拍照) + */ + fun resetState() { + _capturedImage.value = null + _recognitionStatus.value = RecognitionStatus.IDLE + _isRecognizing.value = false + autoCaptureJob?.cancel() + _captureCountdown.value = 0 + } + + override fun onCleared() { + super.onCleared() + countdownJob?.cancel() + autoCaptureJob?.cancel() + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt new file mode 100644 index 0000000..b8aa901 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt @@ -0,0 +1,996 @@ +package com.yzx.kiosk.ui.home.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import com.yzx.kiosk.R +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.ui.common.dialog.PaperLowWarningDialog +import com.yzx.kiosk.ui.common.dialog.PasswordInputDialog +import com.yzx.kiosk.ui.common.dialog.shouldWarnLowPrintPaper +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.home.viewmodel.HomeViewModel +import com.yzx.kiosk.utils.ToastUtils + +@androidx.annotation.OptIn(UnstableApi::class) +@UnstableApi +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + viewModel: HomeViewModel = hiltViewModel(), + onFaceRecognitionClick: () -> Unit = {}, + onUploadPhotoClick: () -> Unit = {}, + isVideoPlaying: Boolean = true +) { + // 使用全屏模式 + FullScreenMode() + + // 每次显示首页时播放首页音频,并检查是否已配置设备密钥 + LaunchedEffect(Unit) { + viewModel.playHomeAudio() + viewModel.promptIfDeviceSecretNotConfigured() + } + + // 监听 isShowQRCode 的变化,确保 UI 更新 + val isShowQRCode by viewModel.isShowQRCode.collectAsState() + val homeNewStyleEnabled by viewModel.homeNewStyleEnabled.collectAsState() + + val qrCodeUrl by viewModel.qrCodeUrl.collectAsState() + val videoUrl by viewModel.videoUrl.collectAsState() + val showPasswordDialog by viewModel.showPasswordDialog.collectAsState() + val remainingPaperNum by viewModel.remainingPaperNum.collectAsState() + val homepageServicePhone by viewModel.homepageServicePhone.collectAsState() + + var paperLowWarningDismissed by remember { mutableStateOf(false) } + LaunchedEffect(remainingPaperNum) { + if (!shouldWarnLowPrintPaper(remainingPaperNum)) { + paperLowWarningDismissed = false + } + } + + AppScaffold( + topBar = { + SharedPrintingStatusBar( + appState = viewModel.publicAppState, + isShowBackIcon = false + ) + } + ) { paddingValues -> + Box( + modifier = Modifier.fillMaxSize() + ) { + // 背景图片 + Image( + painter = painterResource(id = R.mipmap.bg_home), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // 内容区域 + Column( + modifier = Modifier.fillMaxSize() + ) { + // 中间内容区域(按钮和二维码) + MiddleContent( + onFaceRecognitionClick = { viewModel.toPage(AppRoutes.FACE_RECOGNITION) }, + onUploadPhotoClick = { viewModel.toPage(AppRoutes.UPLOAD_PHOTO) }, + qrCodeUrl = qrCodeUrl, + isShowQRCode = isShowQRCode, + homeNewStyleEnabled = homeNewStyleEnabled, + onQrCodeDoubleClick = { + viewModel.showPasswordDialog() + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + + // 底部视频区域 - 白色圆角框架 + BottomVideoPlayer( + videoUrl = videoUrl, + isPlaying = isVideoPlaying, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) + + HomepageService( + remainingPaperNum = remainingPaperNum, + homepageServicePhone = homepageServicePhone, + onDoubleClick = { + viewModel.showPasswordDialog() + }, + ) + } + } + } + + if (shouldWarnLowPrintPaper(remainingPaperNum) && !paperLowWarningDismissed) { + PaperLowWarningDialog( + remainingSheets = remainingPaperNum, + servicePhone = homepageServicePhone, + onDismiss = { paperLowWarningDismissed = true } + ) + } + + // 密码输入对话框 + if (showPasswordDialog) { + PasswordInputDialog( + onDismiss = { + viewModel.hidePasswordDialog() + }, + onConfirm = { password -> + if (viewModel.verifyPassword(password)) { + viewModel.hidePasswordDialog() + // 导航到设置页面 + viewModel.toPage(com.yzx.kiosk.navigation.routes.AppRoutes.SETTINGS) + } else { + ToastUtils.show("密码错误") + } + } + ) + } +} + +@Composable +fun MiddleContent( + onFaceRecognitionClick: () -> Unit, + onUploadPhotoClick: () -> Unit, + qrCodeUrl: String, + isShowQRCode: Boolean, + homeNewStyleEnabled: Boolean, + onQrCodeDoubleClick: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val scrollState = rememberScrollState() + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + if (isShowQRCode) { + // 两个功能按钮 - 垂直排列 + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(if (homeNewStyleEnabled) 12.dp else 24.dp) + ) { + // 人脸识别按钮 + Button( + onClick = onFaceRecognitionClick, + modifier = Modifier + .fillMaxWidth() + .height(78.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF4CAF50) // 绿色 + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.mipmap.ic_face), + contentDescription = "人脸识别", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = if (homeNewStyleEnabled) "打卡点无人机照片打印" else "人脸识别照片", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + } + + if (homeNewStyleEnabled) { + // 打卡点相机照片 + Button( + onClick = onFaceRecognitionClick, + modifier = Modifier + .fillMaxWidth() + .height(78.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3) // 蓝色 + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.mipmap.ic_face), + contentDescription = "人脸识别", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "打卡点相机照片打印", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + + // 上传照片打印按钮 + Button( + onClick = onUploadPhotoClick, + modifier = Modifier + .fillMaxWidth() + .height(78.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3) // 蓝色 + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.mipmap.ic_upload), + contentDescription = "上传照片", + modifier = Modifier.size(32.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "上传手机照片打印", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + // 二维码区域 - 添加双击监听 + QrCodeSection( + qrCodeUrl = qrCodeUrl, + isShowQRCode = isShowQRCode, + homeNewStyleEnabled = homeNewStyleEnabled, + onDoubleClick = onQrCodeDoubleClick + ) + } else { + Column ( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (homeNewStyleEnabled) "照片自助打印机" else "千岛湖自助照片打印机", + fontSize = 54.sp, + fontStyle = FontStyle.Italic, + fontWeight = FontWeight.Bold, + color = Color(0xFF0073FF) + ) + + Spacer(modifier = Modifier.height(if (homeNewStyleEnabled) 25.dp else 60.dp)) + + Row ( + modifier = Modifier + .fillMaxWidth(), + ){ + Column ( + modifier = Modifier + .fillMaxWidth() + .clickable { onFaceRecognitionClick() } + .weight(1f) + .background( + color = Color(0xFF22C55E), + shape = RoundedCornerShape(24.dp) + ) + .aspectRatio(if (homeNewStyleEnabled) 1.1f else 0.87f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + modifier = Modifier + .size(if (homeNewStyleEnabled) 64.dp else 94.dp), + contentDescription = "ic", + painter = painterResource(R.drawable.ic_face_large), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.height(if (homeNewStyleEnabled) 16.dp else 36.dp)) + Text( + text = if (homeNewStyleEnabled) "打卡点无人机照片打印" else "人脸识别照片", + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = Color.White, + ) + } + + Spacer(modifier = Modifier.width(if (homeNewStyleEnabled) 11.dp else 16.dp)) + + Column ( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clickable { if (homeNewStyleEnabled) onFaceRecognitionClick() else onUploadPhotoClick() } + .background( + color = Color(0xFF0073FF), + shape = RoundedCornerShape(24.dp) + ) + .aspectRatio(if (homeNewStyleEnabled) 1.1f else 0.87f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + modifier = Modifier + .size(if (homeNewStyleEnabled) 64.dp else 94.dp), + contentDescription = "ic", + painter = painterResource(if (homeNewStyleEnabled) R.drawable.ic_face_large else R.drawable.ic_upload_large), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.height(if (homeNewStyleEnabled) 16.dp else 36.dp)) + Text( + text = if (homeNewStyleEnabled) "打卡点相机照片打印" else "上传手机照片打印", + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = Color.White, + ) + } + } + + if (homeNewStyleEnabled) { + // 游客手机相册照片 + Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = onUploadPhotoClick, + modifier = Modifier + .fillMaxWidth() + .height(78.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3) // 蓝色 + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_upload_local), + contentDescription = "游客手机相册照片", + modifier = Modifier.size(24.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "游客手机相册照片", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } +} + +@Composable +fun QrCodeSection( + qrCodeUrl: String, + isShowQRCode: Boolean, + homeNewStyleEnabled: Boolean, + onDoubleClick: () -> Unit = {} +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 左侧:二维码区域(根据 isShowQRCode 控制显示/隐藏) + // 使用 AnimatedVisibility 确保平滑的显示/隐藏动画和正确的重组 + AnimatedVisibility( + visible = isShowQRCode, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally() + ) { + if (homeNewStyleEnabled) { + Row ( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + // 二维码容器 - 支持覆盖图标和双击监听 + var tapCount by remember { mutableStateOf(0) } + var lastTapTime by remember { mutableStateOf(0L) } + + // 双击检测:400ms内的两次点击视为双击 + LaunchedEffect(tapCount) { + if (tapCount > 0) { + kotlinx.coroutines.delay(400) + tapCount = 0 // 重置计数 + } + } + + Box( + modifier = Modifier + .size(180.dp) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + val currentTime = System.currentTimeMillis() + if (currentTime - lastTapTime < 400) { + // 双击检测成功(400ms内两次点击) + tapCount++ + if (tapCount >= 2) { + onDoubleClick() + tapCount = 0 + } + } else { + tapCount = 1 + } + lastTapTime = currentTime + } + ) + }, + contentAlignment = Alignment.Center + ) { + // 使用Coil加载在线二维码图片 + if (qrCodeUrl.isNotEmpty()) { + AsyncImage( + model = qrCodeUrl, + contentDescription = "二维码", + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Fit + ) + } else { + // 加载中或占位 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = "微信扫码隐私取片", + color = Color(0xFF000000), + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } + + } else { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 二维码容器 - 支持覆盖图标和双击监听 + var tapCount by remember { mutableStateOf(0) } + var lastTapTime by remember { mutableStateOf(0L) } + + // 双击检测:400ms内的两次点击视为双击 + LaunchedEffect(tapCount) { + if (tapCount > 0) { + kotlinx.coroutines.delay(400) + tapCount = 0 // 重置计数 + } + } + + Box( + modifier = Modifier + .size(210.dp) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + val currentTime = System.currentTimeMillis() + if (currentTime - lastTapTime < 400) { + // 双击检测成功(400ms内两次点击) + tapCount++ + if (tapCount >= 2) { + onDoubleClick() + tapCount = 0 + } + } else { + tapCount = 1 + } + lastTapTime = currentTime + } + ) + }, + contentAlignment = Alignment.Center + ) { + // 使用Coil加载在线二维码图片 + if (qrCodeUrl.isNotEmpty()) { + AsyncImage( + model = qrCodeUrl, + contentDescription = "二维码", + modifier = Modifier + .fillMaxSize(), + contentScale = ContentScale.Fit + ) + } else { + // 加载中或占位 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "微信扫码隐私取片", + color = Color(0xFF000000), + fontSize = 27.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } +} + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun BottomVideoPlayer( + videoUrl: String, + isPlaying: Boolean = true, + modifier: Modifier = Modifier +) { + val context = androidx.compose.ui.platform.LocalContext.current + val TAG = "BottomVideoPlayer" + + // 打印视频地址 + LaunchedEffect(videoUrl) { + // LogUtils.d(TAG, "========== 首页底部视频播放 ==========") + // LogUtils.d(TAG, "视频地址: $videoUrl") + // LogUtils.d(TAG, "视频地址是否为空: ${videoUrl.isEmpty()}") + // if (videoUrl.isNotEmpty()) { + // LogUtils.d(TAG, "视频地址长度: ${videoUrl.length}") + // } + } + + val exoPlayer = remember(videoUrl) { + ExoPlayer.Builder(context).build().apply { + // 添加播放器监听器,用于记录播放状态和错误 + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_IDLE -> { + // LogUtils.d(TAG, "视频播放状态: IDLE (空闲)") + } + + Player.STATE_BUFFERING -> { + // LogUtils.d(TAG, "视频播放状态: BUFFERING (缓冲中)") + } + + Player.STATE_READY -> { + // LogUtils.d(TAG, "视频播放状态: READY (准备就绪)") + // 通过 this@apply 访问 ExoPlayer 的属性 + val durationMs = this@apply.duration + val videoSizeInfo = this@apply.videoSize + // if (durationMs != C.TIME_UNSET) { + // LogUtils.d(TAG, "视频时长: ${durationMs}ms (${durationMs / 1000}秒)") + // } + // LogUtils.d(TAG, "视频分辨率: ${videoSizeInfo.width}x${videoSizeInfo.height}") + } + + Player.STATE_ENDED -> { + // LogUtils.d(TAG, "视频播放状态: ENDED (播放结束)") + // 确保循环播放:如果播放结束,重新开始播放 + if (this@apply.repeatMode == Player.REPEAT_MODE_ONE) { + // LogUtils.d(TAG, "视频播放结束,循环播放:重新开始") + this@apply.seekTo(0) + this@apply.play() + } + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + // LogUtils.d(TAG, "视频播放中状态变化: $isPlaying") + } + + override fun onPlayerError(error: PlaybackException) { + // LogUtils.e(TAG, "========== 视频播放失败 ==========") + // LogUtils.e(TAG, "视频地址: $videoUrl") + // LogUtils.e(TAG, "错误类型: ${error.errorCode}") + // LogUtils.e(TAG, "错误消息: ${error.message}") + // LogUtils.e(TAG, "错误原因: ${error.cause?.message}") + // LogUtils.e(TAG, "错误堆栈: ${error.stackTraceToString()}") + // if (error.cause != null) { + // LogUtils.e(TAG, "原因类型: ${error.cause?.javaClass?.name}") + // LogUtils.e(TAG, "原因消息: ${error.cause?.message}") + // } + // LogUtils.e(TAG, "====================================") + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + // LogUtils.d(TAG, "视频媒体项切换: ${mediaItem?.localConfiguration?.uri}, reason=$reason") + } + + override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { + // val videoTrackCount = tracks.groups.count { it.type == C.TRACK_TYPE_VIDEO } + // val audioTrackCount = tracks.groups.count { it.type == C.TRACK_TYPE_AUDIO } + // LogUtils.d(TAG, "视频轨道变化: 视频轨道数=$videoTrackCount, 音频轨道数=$audioTrackCount, 总轨道数=${tracks.groups.size}") + } + }) + + if (videoUrl.isNotEmpty()) { + try { + // LogUtils.d(TAG, "开始设置视频媒体项: $videoUrl") + val mediaItem = MediaItem.fromUri(videoUrl) + setMediaItem(mediaItem) + // LogUtils.d(TAG, "视频媒体项设置成功,开始准备播放") + prepare() + repeatMode = Player.REPEAT_MODE_ONE // 循环播放 + playWhenReady = isPlaying // 根据参数控制 + // LogUtils.d(TAG, "视频播放器准备完成, playWhenReady=$isPlaying") + } catch (e: Exception) { + // LogUtils.e(TAG, "========== 设置视频媒体项失败 ==========") + // LogUtils.e(TAG, "视频地址: $videoUrl") + // LogUtils.e(TAG, "异常类型: ${e.javaClass.name}") + // LogUtils.e(TAG, "异常消息: ${e.message}") + // LogUtils.e(TAG, "异常堆栈: ${e.stackTraceToString()}") + // LogUtils.e(TAG, "========================================") + } + } else { + // LogUtils.i(TAG, "视频地址为空,无法播放") + } + } + } + + // 当视频地址变化时,重新设置媒体项 + LaunchedEffect(videoUrl) { + if (videoUrl.isNotEmpty()) { + try { + // 如果媒体项已存在且URL相同,不需要重新设置 + val currentMediaItem = exoPlayer.currentMediaItem + val currentUri = currentMediaItem?.localConfiguration?.uri?.toString() + + if (currentUri != videoUrl) { + // LogUtils.d(TAG, "视频地址变化,重新设置媒体项: $videoUrl") + val mediaItem = MediaItem.fromUri(videoUrl) + exoPlayer.setMediaItem(mediaItem) + // 确保循环播放模式 + exoPlayer.repeatMode = Player.REPEAT_MODE_ONE + exoPlayer.prepare() + // LogUtils.d(TAG, "视频媒体项重新设置成功,循环播放模式已设置") + } else { + // LogUtils.d(TAG, "视频地址未变化,无需重新设置") + } + } catch (e: Exception) { + // LogUtils.e(TAG, "重新设置视频媒体项失败: ${e.message}") + // LogUtils.e(TAG, "视频地址: $videoUrl") + // e.printStackTrace() + } + } + } + + // 确保循环播放模式始终生效 + LaunchedEffect(Unit) { + if (videoUrl.isNotEmpty()) { + exoPlayer.repeatMode = Player.REPEAT_MODE_ONE + // LogUtils.d(TAG, "确保循环播放模式已设置: REPEAT_MODE_ONE") + } + } + + // 控制播放/暂停 + LaunchedEffect(isPlaying) { + if (isPlaying) { + // LogUtils.d(TAG, "开始播放视频") + exoPlayer.play() + } else { + // LogUtils.d(TAG, "暂停播放视频") + exoPlayer.pause() + } + } + + DisposableEffect(exoPlayer) { + onDispose { + // LogUtils.d(TAG, "释放视频播放器资源") + // 释放播放器资源(监听器会在 release 时自动清理) + exoPlayer.release() + } + } + + // 白色圆角框架包围 + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1.33f) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + ) { + // 内部深色背景视频区域 + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)) + .background(Color.Black) + ) { + if (videoUrl.isNotEmpty()) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = false // 隐藏控制器 + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + } + }, + modifier = Modifier.fillMaxSize() + ) + } else { + // 视频加载占位 + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Color.White) + } + } + } + } +} + + +@Composable +fun HomepageService( + remainingPaperNum: Int, + homepageServicePhone: String, + onDoubleClick: () -> Unit = {} +) { + // 客服电话 - 支持双击监听 + var servicePhoneTapCount by remember { mutableStateOf(0) } + var servicePhoneLastTapTime by remember { mutableStateOf(0L) } + + // 双击检测:400ms内的两次点击视为双击 + LaunchedEffect(servicePhoneTapCount) { + if (servicePhoneTapCount > 0) { + kotlinx.coroutines.delay(400) + servicePhoneTapCount = 0 // 重置计数 + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // 剩余打印张数 + Row( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { + val currentTime = System.currentTimeMillis() + if (currentTime - servicePhoneLastTapTime < 400) { + // 双击检测成功(400ms内两次点击) + servicePhoneTapCount++ + if (servicePhoneTapCount >= 2) { + onDoubleClick() + servicePhoneTapCount = 0 + } + } else { + servicePhoneTapCount = 1 + } + servicePhoneLastTapTime = currentTime + } + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "剩余打印张数:", + color = Color(0xFF000000), + fontSize = 21.sp, + fontWeight = FontWeight.Normal + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = remainingPaperNum.toString(), + color = Color(0xFF000000), + fontSize = 21.sp, + fontWeight = FontWeight.Normal + ) + } + + Row( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures( + onTap = { + val currentTime = System.currentTimeMillis() + if (currentTime - servicePhoneLastTapTime < 400) { + // 双击检测成功(400ms内两次点击) + servicePhoneTapCount++ + if (servicePhoneTapCount >= 2) { + onDoubleClick() + servicePhoneTapCount = 0 + } + } else { + servicePhoneTapCount = 1 + } + servicePhoneLastTapTime = currentTime + } + ) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "客服电话:", + color = Color(0xFF000000), + fontSize = 21.sp, + fontWeight = FontWeight.Normal + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = homepageServicePhone.ifEmpty { "--" }, + color = Color(0xFF000000), + fontSize = 21.sp, + fontWeight = FontWeight.Normal + ) + } + } +} + + +// 预览数据类 +data class HomePreviewData( + val qrCodeUrl: String = "https://8019973.s21i.faimallusr.com/2/ABUIABACGAAgm9zOrQYosI-21wEwuAg4uAg.jpg", + val videoUrl: String = "http://vfx.mtime.cn/Video/2019/03/18/mp4/190318231014076505.mp4", + val printingStatus: String = "手机尾号1234用户,正在打印中...打印时,不影响当前屏幕操作" +) + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true, name = "首页预览") +@Composable +fun HomeScreenPreview(viewModel: HomeViewModel = hiltViewModel()) { + MaterialTheme { + // 模拟数据 + val previewData = HomePreviewData() + + AppScaffold( + topBar = { + SharedPrintingStatusBar( + appState = viewModel.publicAppState, + isShowBackIcon = false + ) + }, + backgroundColor = Color.White, + showBackIcon = false + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + ) { + // 中间内容区域(按钮和二维码) + MiddleContent( + onFaceRecognitionClick = {}, + onUploadPhotoClick = {}, + qrCodeUrl = previewData.qrCodeUrl, + homeNewStyleEnabled = true, + isShowQRCode = true, + onQrCodeDoubleClick = {}, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) + + // 底部视频区域 - 白色圆角框架 + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1.33f) + .padding(horizontal = 16.dp, vertical = 12.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(2.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Text( + text = "视频播放区域", + color = Color.White, + fontSize = 14.sp + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt new file mode 100644 index 0000000..b313c18 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt @@ -0,0 +1,426 @@ +package com.yzx.kiosk.ui.home.viewmodel + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.lifecycle.viewModelScope +import com.yzx.kiosk.audio.AudioPlayService +import com.yzx.kiosk.audio.LocalAudioPlayService +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.priter.PrintCallback +import com.yzx.kiosk.priter.PrintRecord +import com.yzx.kiosk.priter.PrinterService +import com.yzx.kiosk.utils.InactivityManager +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import com.yzx.kiosk.websocket.PrivacyPrintService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URL +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val printerService: PrinterService, + private val networkRepository: NetWorkRepository, + private val appStoreDataSource: AppStoreDataSource, + private val audioPlayService: AudioPlayService, + private val localAudioPlayService: LocalAudioPlayService, + private val privacyPrintService: PrivacyPrintService, + private val inactivityManager: InactivityManager +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + // 二维码图片URL + private val _qrCodeUrl = MutableStateFlow("") + val qrCodeUrl: StateFlow = _qrCodeUrl.asStateFlow() + + // 视频URL + private val _videoUrl = MutableStateFlow("") + val videoUrl: StateFlow = _videoUrl.asStateFlow() + + private val _homeNewStyleEnabled = MutableStateFlow(false) + val homeNewStyleEnabled: StateFlow = _homeNewStyleEnabled.asStateFlow() + + // 打印状态文本 + private val _printingStatus = MutableStateFlow("") + val printingStatus: StateFlow = _printingStatus.asStateFlow() + + // 密码对话框显示状态 + private val _showPasswordDialog = MutableStateFlow(false) + val showPasswordDialog: StateFlow = _showPasswordDialog.asStateFlow() + + // 是否显示隐私取片二维码 + private val _isShowQRCode = MutableStateFlow(true) + val isShowQRCode: StateFlow = _isShowQRCode.asStateFlow() + + // 首页客服电话 + private val _homepageServicePhone = MutableStateFlow("") + val homepageServicePhone: StateFlow = _homepageServicePhone.asStateFlow() + + // 剩余打印张数 + private val _remainingPaperNum = MutableStateFlow(0) + val remainingPaperNum: StateFlow = _remainingPaperNum.asStateFlow() + + /** 若未配置设备密钥,在首页弹出 Toast 提示 */ + fun promptIfDeviceSecretNotConfigured() { + if (appStoreDataSource.getDeviceSecret().isEmpty()) { + ToastUtils.show("请先配置设备密钥") + } + } + + init { + // 初始化数据,可以从网络或本地获取 + loadHomeData() + _homeNewStyleEnabled.value = appStoreDataSource.getHomeNewStyleEnabled() + // 初始化默认消息 + _printingStatus.value = "当前无小程序用户操作打印。打印时,不影响当前屏幕操作" + // 初始化时通知 InactivityManager + inactivityManager.updatePrintingStatus(_printingStatus.value) + + // 初始化剩余打印张数(从本地读取) + _remainingPaperNum.value = appStoreDataSource.getRemainingPaperNum() + + // 初始化是否显示二维码(从本地读取) + _isShowQRCode.value = appStoreDataSource.getShowWechatScanPrivacy() + + // 初始化首页客服电话(从本地读取) + _homepageServicePhone.value = appStoreDataSource.getHomepageServicePhone() + + // 监听打印通知 + viewModelScope.launch { + privacyPrintService.printNotification.collect { notification -> + if (notification != null) { + _printingStatus.value = notification + } else { + // 队列为空时显示默认消息 + _printingStatus.value = "当前无小程序用户操作打印。打印时,不影响当前屏幕操作" + } + // 通知 InactivityManager 打印状态变化 + inactivityManager.updatePrintingStatus(_printingStatus.value) + } + } + + // 监听全局剩余打印张数变化(实时更新) + viewModelScope.launch { + appState.remainingPaperNum.collect { num -> + _remainingPaperNum.value = num + } + } + + // 定期刷新 isShowQRCode 和 homepageServicePhone(从本地存储读取最新值) + viewModelScope.launch { + while (true) { + kotlinx.coroutines.delay(1000) // 每秒检查一次 + val latestIsShowQRCode = appStoreDataSource.getShowWechatScanPrivacy() + val latestServicePhone = appStoreDataSource.getHomepageServicePhone() + val lastHomeNewStyleEnabled = appStoreDataSource.getHomeNewStyleEnabled() + + if (_homeNewStyleEnabled.value != lastHomeNewStyleEnabled) { + _homeNewStyleEnabled.value = lastHomeNewStyleEnabled + } + // 如果值发生变化,更新 StateFlow + if (_isShowQRCode.value != latestIsShowQRCode) { + _isShowQRCode.value = latestIsShowQRCode + } + if (_homepageServicePhone.value != latestServicePhone) { + _homepageServicePhone.value = latestServicePhone + } + } + } + } + + /** + * 播放首页音频 + */ + fun playHomeAudio() { + viewModelScope.launch { + localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.HOME, forceRestart = true) + } + } + + fun showPasswordDialog() { + _showPasswordDialog.value = true + } + + fun hidePasswordDialog() { + _showPasswordDialog.value = false + } + + fun verifyPassword(password: String): Boolean { + return password == CORRECT_PASSWORD + } + + private fun loadHomeData() { + viewModelScope.launch { + // 调用接口获取配置信息 + handleResultWithData( + flow = networkRepository.getEquipmentConfig().asResult(), + showToast = false, + onData = { config -> + // 保存配置到本地 + if (config.posters != null && config.posters.isNotEmpty()) { + // 保存已选海报URL列表(posters 现在是字符串数组) + appStoreDataSource.savePosterList(config.posters) + } + + if (config.bgm != null) { + val oldBgm = appStoreDataSource.getBgm() + appStoreDataSource.saveBgm(config.bgm) + // 如果有新的BGM且与旧的不同,立即播放新的音频 +// if (config.bgm != oldBgm && config.bgm.isNotEmpty()) { +// // 使用 forceRestart = true 强制播放新音频 +// audioPlayService.play(config.bgm, forceRestart = true) +// LogUtils.d(TAG, "检测到新的BGM,立即播放: ${config.bgm}") +// } + } + + // 保存 bind_box_url + if (config.bindBoxUrl != null) { + appStoreDataSource.saveBindBoxUrl(config.bindBoxUrl) + } + + // 保存 bind_box_lan_url + if (config.bindBoxLanUrl != null) { + appStoreDataSource.saveBindBoxLanUrl(config.bindBoxLanUrl) + } + + // 保存 bind_box_sn + if (config.bindBoxSn != null) { + appStoreDataSource.saveBindBoxSn(config.bindBoxSn) + } + + // 保存 bind_box_api_token (同时作为 Face Search Token) + if (config.bindBoxApiToken != null) { + appStoreDataSource.saveBindBoxApiToken(config.bindBoxApiToken) + appStoreDataSource.saveFaceSearchToken(config.bindBoxApiToken) + } + + // 保存 bind_box_lan_ip + if (config.bindBoxLanIp != null) { + appStoreDataSource.saveBindBoxLanIp(config.bindBoxLanIp) + } + + if (config.scenicId > 0) { + appStoreDataSource.saveScenicId(config.scenicId.toString()) + } + + // 保存 scenic_name + if (config.scenicName != null) { + appStoreDataSource.saveScenicName(config.scenicName) + } + + if (config.name.isNotEmpty()) { + appStoreDataSource.saveDeviceName(config.name) + } + + if (config.ownerMobile != null) { + appStoreDataSource.saveTechSupportPhone(config.ownerMobile) + } + + if (config.serviceTel != null) { + appStoreDataSource.saveServicePhone(config.serviceTel) + } + + // 保存首页客服电话 + if (config.servicePhone != null) { + appStoreDataSource.saveHomepageServicePhone(config.servicePhone) + } + + // 保存首页微信扫码隐私取片开关状态 + if (config.isShowQRCode != null) { + appStoreDataSource.saveShowWechatScanPrivacy(config.isShowQRCode) + _isShowQRCode.value = config.isShowQRCode + } + + // 更新首页客服电话 + if (config.servicePhone != null) { + appStoreDataSource.saveHomepageServicePhone(config.servicePhone) + _homepageServicePhone.value = config.servicePhone + } + + // 设置首页底部视频URL(从 global_config.bottom_video 获取) + if (config.globalConfig?.bottomVideo != null && config.globalConfig.bottomVideo.isNotEmpty()) { + _videoUrl.value = config.globalConfig.bottomVideo + } else { + // 如果不存在或为空,则清空视频URL + _videoUrl.value = "" + } + + // 支付成功页打印按钮图(global_config.button_print) + appStoreDataSource.saveButtonPrintUrl( + config.globalConfig?.buttonPrint?.trim().orEmpty() + ) + + // 设置二维码URL(优先使用 sun_qr_url,其次使用 qrcode.scanUploadUrl) + when { + config.sunQrUrl != null && config.sunQrUrl.isNotEmpty() -> { + _qrCodeUrl.value = config.sunQrUrl + } + config.qrcode?.scanUploadUrl != null && config.qrcode.scanUploadUrl.isNotEmpty() -> { + _qrCodeUrl.value = config.qrcode.scanUploadUrl + } + else -> { + // 如果都没有,则清空(不设置默认值) + _qrCodeUrl.value = "" + } + } + + LogUtils.d(TAG, "设备配置加载成功") + }, + onError = { msg, throwable -> + LogUtils.e( "获取设备配置失败: $msg") + // 失败时不设置默认值,保持为空 + } + ) + } + } + + /** + * 设置二维码URL + */ + fun setQrCodeUrl(url: String) { + _qrCodeUrl.value = url + } + + /** + * 设置视频URL + */ + fun setVideoUrl(url: String) { + _videoUrl.value = url + } + + /** + * 更新打印状态 + */ + fun updatePrintingStatus(status: String) { + _printingStatus.value = status + // 通知 InactivityManager 打印状态变化 + inactivityManager.updatePrintingStatus(status) + } + + // 打印记录列表 + private val _printRecords = MutableStateFlow>(emptyList()) + val printRecords: StateFlow> = _printRecords.asStateFlow() + + // 是否正在打印 + private val _isPrinting = MutableStateFlow(false) + val isPrinting: StateFlow = _isPrinting.asStateFlow() + + companion object { + const val CORRECT_PASSWORD = "20251228" + private const val TAG = "HomeViewModel" + + // 测试图片URL + private const val TEST_IMAGE_URLS = "https://pic.rmb.bdstatic.com/bjh/3f118f246fd7/240911/57b91f5c22ceb11424c60831349da471.jpeg,https://pic.rmb.bdstatic.com/bjh/e49c252c13080b2eb34e5a1b792e76e63030.jpeg,https://pic.rmb.bdstatic.com/bjh/3f118f246fd7/240911/d9fdd504a60dbfb6fa5ee7c6f69eb7fe.jpeg" + } + + /** + * 上传图片打印 + */ + fun onUploadPhotoPrint() { + if (_isPrinting.value) { + ToastUtils.show("正在打印中,请稍候...") + return + } + + viewModelScope.launch { + _isPrinting.value = true + _printRecords.value = emptyList() + + // 初始化打印机 + val initResult = printerService.initDnpPrint() + if (!initResult) { + ToastUtils.show("打印机初始化失败") + _isPrinting.value = false + return@launch + } + + // 分割图片URL + val imageUrls = TEST_IMAGE_URLS.split(",") + val records = mutableListOf() + + // 顺序打印每张图片 + for ((index, url) in imageUrls.withIndex()) { + val fileName = url.substringAfterLast("/") + updatePrintingStatus("正在打印第${index + 1}/${imageUrls.size}张: $fileName") + + // 下载图片 + val bitmap = downloadImage(url) + if (bitmap == null) { + val record = PrintRecord( + fileName = fileName, + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "图片下载失败" + ) + records.add(record) + _printRecords.value = records.toList() + appStoreDataSource.addPrintRecord(record) + continue + } + + // 打印图片 + var printSuccess = false + var printMessage = "" + + printerService.startPrint(bitmap, object : PrintCallback { + override fun onPrintResult(isSuccess: Boolean, msg: String) { + printSuccess = isSuccess + printMessage = msg + } + }) + + // 生成打印记录 + val record = PrintRecord( + fileName = fileName, + printEndTime = System.currentTimeMillis(), + isSuccess = printSuccess, + message = printMessage + ) + records.add(record) + _printRecords.value = records.toList() + appStoreDataSource.addPrintRecord(record) + + LogUtils.d(TAG, "打印记录: $record") + } + + // 所有图片打印完成 + _isPrinting.value = false + val successCount = records.count { it.isSuccess } + val failCount = records.size - successCount + ToastUtils.show("打印完成!成功${successCount}张,失败${failCount}张") + updatePrintingStatus("打印完成!成功${successCount}张,失败${failCount}张") + } + } + + /** + * 下载图片 + */ + private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) { + try { + val connection = URL(url).openConnection() + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + connection.getInputStream().use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } + } catch (e: Exception) { + LogUtils.e(TAG, "下载图片失败: ${e.message}") + null + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/poster/ScenicLivePosterController.kt b/app/src/main/java/com/yzx/kiosk/ui/poster/ScenicLivePosterController.kt new file mode 100644 index 0000000..8fe2aba --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/poster/ScenicLivePosterController.kt @@ -0,0 +1,45 @@ +package com.yzx.kiosk.ui.poster + +import com.yzx.kiosk.datastore.AppStoreDataSource +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 景区直播海报:WebSocket scenic_live_start + /api/oscar/lives 下发的 play_urls.auto + */ +@Singleton +class ScenicLivePosterController @Inject constructor( + private val appStoreDataSource: AppStoreDataSource +) { + private val _dynamicRtmpUrl = MutableStateFlow(appStoreDataSource.getPosterScenicLiveRtmpUrl().takeIf { it.isNotBlank() }) + val dynamicRtmpUrl: StateFlow = _dynamicRtmpUrl.asStateFlow() + + fun setDynamicRtmpUrl(url: String) { + val trimmed = url.trim() + if (trimmed.isEmpty()) return + val newKey = liveStreamIdentityKey(trimmed) + val current = _dynamicRtmpUrl.value?.trim() + if (!current.isNullOrEmpty() && liveStreamIdentityKey(current) == newKey) { + return + } + _dynamicRtmpUrl.value = trimmed + appStoreDataSource.savePosterScenicLiveRtmpUrl(trimmed) + } + + fun clearScenicLive() { + _dynamicRtmpUrl.value = null + appStoreDataSource.savePosterScenicLiveRtmpUrl("") + } +} + +/** + * 用于判断是否为「同一直播」:忽略 `?` 及之后整段查询串(含变化的 auth_key),只比较 rtmp://host/path 部分。 + */ +private fun liveStreamIdentityKey(url: String): String { + val t = url.trim() + val q = t.indexOf('?') + return if (q >= 0) t.substring(0, q) else t +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreen.kt new file mode 100644 index 0000000..a60b3ab --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreen.kt @@ -0,0 +1,221 @@ +package com.yzx.kiosk.ui.poster.view + +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import com.yzx.kiosk.ui.common.view.FullScreenMode +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first + +/** + * 海报数据类 + */ +data class PosterData( + val url: String, + val isVideo: Boolean +) + +/** + * 海报轮播页面 + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PosterScreen( + posters: List, + onUserInteraction: () -> Unit = {} +) { + if (posters.isEmpty()) return + + FullScreenMode() + + val context = LocalContext.current + + // 使用大数实现无限循环 + val infinitePageCount = Int.MAX_VALUE + val initialPage = infinitePageCount / 2 - (infinitePageCount / 2) % posters.size + val pagerState = rememberPagerState( + initialPage = initialPage, + pageCount = { infinitePageCount } + ) + + // 计算实际页面索引 + val actualPage = pagerState.currentPage % posters.size + val currentPoster = posters.getOrNull(actualPage) + val isCurrentVideo = currentPoster?.isVideo == true + + // 视频播放完成标志 - 使用 Int 作为触发器 + var videoCompletedTrigger by remember { mutableIntStateOf(0) } + + // 图片自动轮播 - 3秒切换(仅当有多个海报时) + LaunchedEffect(pagerState.settledPage) { + // 如果只有一个海报,不自动切换 + if (posters.size <= 1) return@LaunchedEffect + + val currentActualPage = pagerState.settledPage % posters.size + val poster = posters.getOrNull(currentActualPage) + + if (poster?.isVideo != true) { + // 图片页面:等待3秒后切换 + delay(3000) + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + + // 视频播放完成后自动切换(仅当有多个海报时) + LaunchedEffect(videoCompletedTrigger) { + // 如果只有一个海报,不自动切换 + if (posters.size <= 1) return@LaunchedEffect + + if (videoCompletedTrigger > 0) { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { onUserInteraction() } + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + val actualIndex = page % posters.size + val poster = posters[actualIndex] + val isCurrentPage = pagerState.settledPage == page + + if (poster.isVideo) { + // 视频页面 + PosterVideoPlayer( + url = poster.url, + isActive = isCurrentPage, + onVideoCompleted = { + // 仅当有多个海报时,才触发切换 + if (posters.size > 1) { + videoCompletedTrigger++ + } + } + ) + } else { + // 图片页面 + AsyncImage( + model = poster.url, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } + + // 右上角"点击开始"按钮 + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 60.dp, end = 24.dp) + .size(80.dp) + .background(Color(0xFFE53935), CircleShape) + .clickable { onUserInteraction() }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "点击", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + Text( + text = "开始", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } + } +} + +/** + * 海报视频播放器 + */ +@Composable +fun PosterVideoPlayer( + url: String, + isActive: Boolean, + onVideoCompleted: () -> Unit +) { + val context = LocalContext.current + + // 使用 rememberUpdatedState 确保回调使用最新值 + val currentOnVideoCompleted by rememberUpdatedState(onVideoCompleted) + + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(Uri.parse(url))) + prepare() + repeatMode = Player.REPEAT_MODE_OFF + } + } + + // 监听播放完成 + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + currentOnVideoCompleted() + } + } + } + exoPlayer.addListener(listener) + + onDispose { + exoPlayer.removeListener(listener) + exoPlayer.release() + } + } + + // 控制播放/暂停 + LaunchedEffect(isActive) { + if (isActive) { + exoPlayer.seekTo(0) + exoPlayer.play() + } else { + exoPlayer.pause() + } + } + + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = false + } + }, + modifier = Modifier.fillMaxSize() + ) +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreenWithLiveOrSlideshow.kt b/app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreenWithLiveOrSlideshow.kt new file mode 100644 index 0000000..bd3a7a8 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreenWithLiveOrSlideshow.kt @@ -0,0 +1,255 @@ +package com.yzx.kiosk.ui.poster.view + +import android.net.Uri +import android.view.LayoutInflater +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.rtmp.RtmpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.PlayerView +import com.yzx.kiosk.R +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils + +private const val TAG = "PosterLive" + +/** + * 海报层:若开启直播且配置了地址,先尝试播放直播;失败则回退为轮播海报。 + * 无海报素材且直播失败时,仅显示退出入口。 + */ +@Composable +fun PosterScreenWithLiveOrSlideshow( + liveEnabled: Boolean, + liveStreamUrl: String, + posters: List, + onUserInteraction: () -> Unit, + onLivePlaybackActive: (Boolean) -> Unit, + /** 是否正在展示直播海报层(含拉流缓冲);用于待机 BGM 仅在非直播海报时播放 */ + onPosterLiveLayerVisible: (Boolean) -> Unit = {}, + showScenicLiveBadge: Boolean = false +) { + val trimmedUrl = liveStreamUrl.trim() + val tryLiveFirst = liveEnabled && trimmedUrl.isNotBlank() + var useSlideshow by remember(tryLiveFirst) { mutableStateOf(!tryLiveFirst) } + + // 与分支判断同步通知父级(含缓冲阶段),避免 DisposableEffect 顺序靠后导致 BGM 先响一瞬 + val showingLivePoster = tryLiveFirst && !useSlideshow + val onLayerVisibleUpdated by rememberUpdatedState(onPosterLiveLayerVisible) + SideEffect { + onLayerVisibleUpdated(showingLivePoster) + } + + if (!useSlideshow && tryLiveFirst) { + PosterLiveStreamLayer( + url = trimmedUrl, + showScenicLiveBadge = showScenicLiveBadge, + onPlaybackFailed = { + LogUtils.w(TAG, "直播播放失败,回退到海报轮播") + useSlideshow = true + }, + onPlaybackActive = onLivePlaybackActive, + onUserInteraction = onUserInteraction + ) + } else if (posters.isNotEmpty()) { + PosterScreen(posters = posters, onUserInteraction = onUserInteraction) + } else { + PosterEmptyExitOnly(onUserInteraction = onUserInteraction) + } +} + +@Composable +private fun PosterLiveStreamLayer( + url: String, + showScenicLiveBadge: Boolean, + onPlaybackFailed: () -> Unit, + onPlaybackActive: (Boolean) -> Unit, + onUserInteraction: () -> Unit +) { + FullScreenMode() + + LaunchedEffect(url) { + ToastUtils.show("直播地址:$url") + } + + val context = LocalContext.current + val onFailedUpdated by rememberUpdatedState(onPlaybackFailed) + val onActiveUpdated by rememberUpdatedState(onPlaybackActive) + + val exoPlayer = remember(url) { + val builder = if (url.startsWith("rtmp", ignoreCase = true)) { + val dataSourceFactory = DefaultDataSource.Factory(context, RtmpDataSource.Factory()) + ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + } else { + ExoPlayer.Builder(context) + } + builder.build().apply { + setMediaItem(MediaItem.fromUri(Uri.parse(url))) + prepare() + playWhenReady = true + } + } + + DisposableEffect(exoPlayer) { + val listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + onActiveUpdated(true) + } + } + + override fun onPlayerError(error: PlaybackException) { + LogUtils.e(TAG, "直播播放错误: ${error.message}") + onActiveUpdated(false) + onFailedUpdated() + } + } + exoPlayer.addListener(listener) + onDispose { + exoPlayer.removeListener(listener) + onActiveUpdated(false) + exoPlayer.release() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { onUserInteraction() } + ) { + AndroidView( + factory = { ctx -> + LayoutInflater.from(ctx).inflate(R.layout.poster_live_player_view, null, false) as PlayerView + }, + update = { view -> + view.player = exoPlayer + }, + modifier = Modifier.fillMaxSize() + ) + if (showScenicLiveBadge) { + ScenicLiveStatusBadge( + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 60.dp, start = 24.dp) + ) + } + PosterClickStartButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 60.dp, end = 24.dp), + onClick = onUserInteraction + ) + } +} + +@Composable +private fun ScenicLiveStatusBadge(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background(Color(0x99000000), RoundedCornerShape(20.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(Color(0xFFE53935), CircleShape) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "直播中", + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +private fun PosterEmptyExitOnly(onUserInteraction: () -> Unit) { + LaunchedEffect(Unit) { + ToastUtils.show("没有设置海报") + } + FullScreenMode() + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .clickable { onUserInteraction() } + ) { + PosterClickStartButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 60.dp, end = 24.dp), + onClick = onUserInteraction + ) + } +} + +@Composable +private fun PosterClickStartButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Box( + modifier = modifier + .size(80.dp) + .background(Color(0xFFE53935), CircleShape) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "点击", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + Text( + text = "开始", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/printer/view/PrinterManageScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/printer/view/PrinterManageScreen.kt new file mode 100644 index 0000000..ac55dcf --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/printer/view/PrinterManageScreen.kt @@ -0,0 +1,348 @@ +package com.yzx.kiosk.ui.printer.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.priter.PrintRecord +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.printer.viewmodel.PrinterManageViewModel +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrinterManageScreen( + viewModel: PrinterManageViewModel = hiltViewModel() +) { + val printerName by viewModel.printerName.collectAsState() + val printerSN by viewModel.printerSN.collectAsState() + val isOnline by viewModel.isOnline.collectAsState() + val ipAddress by viewModel.ipAddress.collectAsState() + val printRecords by viewModel.printRecords.collectAsState() + val isPrinting by viewModel.isPrinting.collectAsState() + + var showEditDialog by remember { mutableStateOf(false) } + var editingIp by remember { mutableStateOf("") } + + // 进入页面初始化打印机 + LaunchedEffect(Unit) { + viewModel.initPrinter() + } + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBar( + backgroundColor = Color.White, + title = "打印机管理", + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = false + ) + SharedPrintingStatusBar( + appState = viewModel.publicAppState, + isShowBackIcon = false + ) + } + }, + backgroundColor = Color(0xFFF5F5F5) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + // 打印机信息卡片 + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // 打印机名称 + Text( + text = printerName, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // SN号 + Text( + text = "SN: $printerSN", + fontSize = 14.sp, + color = Color(0xFF666666) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 在线/离线状态 + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 在线标签 + Box( + modifier = Modifier + .background( + color = if (isOnline) Color(0xFFE8F5E9) else Color(0xFFF5F5F5), + shape = RoundedCornerShape(4.dp) + ) + .border( + width = 1.dp, + color = if (isOnline) Color(0xFF4CAF50) else Color(0xFFE0E0E0), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = "在线", + fontSize = 12.sp, + color = if (isOnline) Color(0xFF4CAF50) else Color(0xFFBDBDBD) + ) + } + + // 离线标签 + Box( + modifier = Modifier + .background( + color = if (!isOnline) Color(0xFFFBE9E7) else Color(0xFFF5F5F5), + shape = RoundedCornerShape(4.dp) + ) + .border( + width = 1.dp, + color = if (!isOnline) Color(0xFFFF5722) else Color(0xFFE0E0E0), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = "离线", + fontSize = 12.sp, + color = if (!isOnline) Color(0xFFFF5722) else Color(0xFFBDBDBD) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // IP地址 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "IP 地址: $ipAddress", + fontSize = 14.sp, + color = Color.Black + ) + + Text( + text = "编辑", + fontSize = 14.sp, + color = Color(0xFF2196F3), + modifier = Modifier.clickable { + editingIp = ipAddress + showEditDialog = true + } + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + // 打印测试按钮 + Button( + onClick = { viewModel.testPrint() }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3) + ), + enabled = !isPrinting + ) { + Text( + text = if (isPrinting) "打印中..." else "打印测试", + fontSize = 16.sp, + color = Color.White + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 打印记录卡片 + Card( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Text( + text = "打印记录", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (printRecords.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "暂无打印记录", + fontSize = 14.sp, + color = Color(0xFF999999) + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + itemsIndexed(printRecords) { index, record -> + PrintRecordItem(record) + // 添加分隔线(最后一条不添加) + if (index < printRecords.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = Color(0xFFEEEEEE), + thickness = 1.dp + ) + } + } + } + } + } + } + } + } + + // IP编辑对话框 + if (showEditDialog) { + AlertDialog( + onDismissRequest = { showEditDialog = false }, + title = { Text("编辑IP地址") }, + text = { + OutlinedTextField( + value = editingIp, + onValueChange = { editingIp = it }, + label = { Text("IP地址") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.updateIpAddress(editingIp) + showEditDialog = false + } + ) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = { showEditDialog = false }) { + Text("取消") + } + } + ) + } +} + +@Composable +fun PrintRecordItem(record: PrintRecord) { + val dateFormat = remember { SimpleDateFormat("yyyy年MM月dd日 HH:mm", Locale.CHINA) } + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + // 文件名 + Text( + text = record.fileName, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // 日期时间 + Text( + text = dateFormat.format(Date(record.printEndTime)), + fontSize = 12.sp, + color = Color(0xFF999999) + ) + + // 显示错误原因(失败时显示) + if (!record.isSuccess && record.message.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "错误原因: ${record.message}", + fontSize = 12.sp, + color = Color(0xFFE53935) + ) + } + } + + // 成功/失败标签(右侧对齐) + Box( + modifier = Modifier + .background( + color = if (record.isSuccess) Color(0xFFE8F5E9) else Color(0xFFFFEBEE), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = if (record.isSuccess) "成功" else "失败", + fontSize = 12.sp, + color = if (record.isSuccess) Color(0xFF4CAF50) else Color(0xFFE53935) + ) + } + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/printer/viewmodel/PrinterManageViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/printer/viewmodel/PrinterManageViewModel.kt new file mode 100644 index 0000000..1329e88 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/printer/viewmodel/PrinterManageViewModel.kt @@ -0,0 +1,223 @@ +package com.yzx.kiosk.ui.printer.viewmodel + +import android.content.Context +import android.graphics.BitmapFactory +import android.net.wifi.WifiManager +import androidx.lifecycle.viewModelScope +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.priter.PrintCallback +import com.yzx.kiosk.priter.PrintRecord +import com.yzx.kiosk.priter.PrinterService +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.URL +import javax.inject.Inject + +@HiltViewModel +class PrinterManageViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val printerService: PrinterService, + private val appStoreDataSource: AppStoreDataSource, + @ApplicationContext private val context: Context +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + companion object { + private const val TAG = "PrinterManageViewModel" + private const val TEST_IMAGE_URL = "https://pic.rmb.bdstatic.com/bjh/3f118f246fd7/240911/d9fdd504a60dbfb6fa5ee7c6f69eb7fe.jpeg" + } + + // 打印机名称(端口号) + private val _printerName = MutableStateFlow("未检测到打印机") + val printerName: StateFlow = _printerName.asStateFlow() + + // 打印机SN(打印机ID) + private val _printerSN = MutableStateFlow("--") + val printerSN: StateFlow = _printerSN.asStateFlow() + + // 在线状态 + private val _isOnline = MutableStateFlow(false) + val isOnline: StateFlow = _isOnline.asStateFlow() + + // IP地址 + private val _ipAddress = MutableStateFlow("0.0.0.0") + val ipAddress: StateFlow = _ipAddress.asStateFlow() + + // 打印记录 + private val _printRecords = MutableStateFlow>(emptyList()) + val printRecords: StateFlow> = _printRecords.asStateFlow() + + // 是否正在打印 + private val _isPrinting = MutableStateFlow(false) + val isPrinting: StateFlow = _isPrinting.asStateFlow() + + init { + getDeviceIpAddress() + // 从本地加载打印记录 + loadPrintRecords() + } + + /** + * 从本地加载打印记录 + */ + private fun loadPrintRecords() { + val records = appStoreDataSource.getPrintRecords() + _printRecords.value = records + LogUtils.d(TAG, "从本地加载打印记录: ${records.size}条") + } + + /** + * 初始化打印机 + */ + fun initPrinter() { + viewModelScope.launch { + val result = printerService.initDnpPrint() + + if (result) { + val printId = printerService.getPrintId() + _printerSN.value = printId.toString() + _printerName.value = "DNP打印机 端口$printId" + _isOnline.value = true + LogUtils.d(TAG, "打印机初始化成功,ID: $printId") + } else { + _isOnline.value = false + _printerSN.value = "--" + _printerName.value = "未检测到打印机" + LogUtils.e(TAG, "打印机初始化失败") + } + } + } + + /** + * 获取设备IP地址 + */ + private fun getDeviceIpAddress() { + try { + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val wifiInfo = wifiManager.connectionInfo + val ipInt = wifiInfo.ipAddress + + if (ipInt != 0) { + val ip = String.format( + "%d.%d.%d.%d", + ipInt and 0xff, + ipInt shr 8 and 0xff, + ipInt shr 16 and 0xff, + ipInt shr 24 and 0xff + ) + _ipAddress.value = ip + } + } catch (e: Exception) { + LogUtils.e(TAG, "获取IP地址失败: ${e.message}") + } + } + + /** + * 更新IP地址(仅展示) + */ + fun updateIpAddress(ip: String) { + _ipAddress.value = ip + } + + /** + * 打印测试 + */ + fun testPrint() { + if (!_isOnline.value) { + ToastUtils.show("未检测到打印机") + return + } + + if (_isPrinting.value) { + ToastUtils.show("正在打印中...") + return + } + + viewModelScope.launch { + _isPrinting.value = true + + // 下载图片 + val bitmap = withContext(Dispatchers.IO) { + try { + val connection = URL(TEST_IMAGE_URL).openConnection() + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + connection.getInputStream().use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } + } catch (e: Exception) { + LogUtils.e(TAG, "下载测试图片失败: ${e.message}") + null + } + } + + if (bitmap == null) { + val record = PrintRecord( + fileName = "测试图片.jpeg", + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "图片下载失败" + ) + addPrintRecord(record) + _isPrinting.value = false + ToastUtils.show("图片下载失败") + return@launch + } + + // 打印图片 - 使用变量存储回调结果 + var printSuccess = false + var printMessage = "" + + printerService.startPrint(bitmap, object : PrintCallback { + override fun onPrintResult(isSuccess: Boolean, msg: String) { + printSuccess = isSuccess + printMessage = msg + } + }) + + // 回调执行后处理结果(在主线程) + withContext(Dispatchers.Main) { + val record = PrintRecord( + fileName = "测试图片.jpeg", + printEndTime = System.currentTimeMillis(), + isSuccess = printSuccess, + message = printMessage + ) + addPrintRecord(record) + _isPrinting.value = false + + if (printSuccess) { + ToastUtils.show("打印成功") + } else { + ToastUtils.show("打印失败: $printMessage") + } + } + } + } + + /** + * 添加打印记录 + */ + private fun addPrintRecord(record: PrintRecord) { + val currentRecords = _printRecords.value.toMutableList() + currentRecords.add(0, record) // 添加到列表头部 + _printRecords.value = currentRecords + // 保存到本地 + appStoreDataSource.addPrintRecord(record) + LogUtils.d(TAG, "添加打印记录并保存到本地: $record") + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/setting/navigation/AgreementGraph.kt b/app/src/main/java/com/yzx/kiosk/ui/setting/navigation/AgreementGraph.kt new file mode 100644 index 0000000..a5c16ae --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/setting/navigation/AgreementGraph.kt @@ -0,0 +1,12 @@ +package com.yzx.kiosk.ui.setting.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController + +/** + * 主模块导航图 + */ +fun NavGraphBuilder.agreementGraph(navController: NavHostController) { + agreementScreen() +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/setting/navigation/AgreementNavigator.kt b/app/src/main/java/com/yzx/kiosk/ui/setting/navigation/AgreementNavigator.kt new file mode 100644 index 0000000..3b207c5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/setting/navigation/AgreementNavigator.kt @@ -0,0 +1,37 @@ +package com.yzx.kiosk.ui.setting.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.ui.setting.view.AgreementRoute + + +object AgreementRoutes{ + const val AGREEMENT_URL = "agreement_url" + const val AGREEMENT_NAME = "agreement_name" + // 修复:去掉末尾的 &,使用可选参数模式 + const val AGREEMENT_PATTERN = "${AppRoutes.AGREEMENT}?$AGREEMENT_URL={$AGREEMENT_URL}&$AGREEMENT_NAME={$AGREEMENT_NAME}" +} + +fun NavGraphBuilder.agreementScreen() { + composable( + AgreementRoutes.AGREEMENT_PATTERN, arguments = listOf( + navArgument(AgreementRoutes.AGREEMENT_URL) { + type = NavType.StringType + }, + navArgument(AgreementRoutes.AGREEMENT_NAME) { + type = NavType.StringType + }, + ) + ) { + AgreementRoute() + } +} + + + + + + diff --git a/app/src/main/java/com/yzx/kiosk/ui/setting/view/AgreementScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/setting/view/AgreementScreen.kt new file mode 100644 index 0000000..18fc898 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/setting/view/AgreementScreen.kt @@ -0,0 +1,128 @@ +package com.yzx.kiosk.ui.setting.view + +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.ui.setting.viewmodel.AgreementViewModel +import kotlin.apply + +@Composable +internal fun AgreementRoute( + + viewModel: AgreementViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val newUrl by viewModel.url.collectAsState() + val title by viewModel.title.collectAsState() + + AgreementScreen( + onBack = viewModel::navigateBack, + title = title, + newUrl = newUrl, + viewModel = viewModel + //loadingProgress = loadingProgress, + //isLoading = isLoading + ) + + +} + + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AgreementScreen( + onBack: () -> Unit = {}, + title: String = "", + newUrl: String = "", + viewModel : AgreementViewModel + //loadingProgress:loadingProgress + +) { + var isLoading by remember { mutableStateOf(true) } + var webTitle by remember { mutableStateOf("") } + + Scaffold( + topBar = { + AppTitleBar( + title = title, + onBackClick = onBack, + ) + // TopAppBar(title = { Text(text = webTitle.takeIf { it.isNotEmpty() } ?: "加载中...") }) + + }, + ) + { innerPadding -> + + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + AndroidView( + factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + isLoading = false + webTitle = view?.title ?: "" + } + } + } + }, + modifier = Modifier + + .fillMaxSize(), + + update = { webView -> + // 当 URL 变化时重新加载页面 + if (webView.url != newUrl) { + webView.loadUrl(newUrl) + } + } + + + ) + + // 显示加载指示器 - 现在在 Box 布局中可以使用 align + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp) + ) + } + + + + } + + + } + + +} + + + + diff --git a/app/src/main/java/com/yzx/kiosk/ui/setting/view/SettingScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/setting/view/SettingScreen.kt new file mode 100644 index 0000000..0bb2f15 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/setting/view/SettingScreen.kt @@ -0,0 +1,444 @@ +package com.yzx.kiosk.ui.setting.view + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.yzx.kiosk.ext.findActivity +import androidx.hilt.navigation.compose.hiltViewModel +import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.ui.common.dialog.PaperNumInputDialog +import com.yzx.kiosk.ui.common.dialog.UpdateDialog +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.setting.viewmodel.SettingViewModel +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.lifecycle.viewModelScope + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingScreen( + viewModel: SettingViewModel = hiltViewModel() +) { + FullScreenMode() + + val context = LocalContext.current + + // 版本更新相关状态 + val showUpdateDialog by viewModel.versionUpdateManager.showUpdateDialog.collectAsState() + val latestVersion by viewModel.versionUpdateManager.latestVersion.collectAsState() + val downloadProgress by viewModel.versionUpdateManager.downloadProgress.collectAsState() + val isDownloading by viewModel.versionUpdateManager.isDownloading.collectAsState() + val isForceUpdate = viewModel.versionUpdateManager.isForceUpdate() + val currentVersionName = viewModel.versionUpdateManager.getCurrentVersionName() + + AppScaffold( + topBar = { + Column { + AppTitleBar( + backgroundColor = Color.White, + title = "设置", + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = true + ) + SharedPrintingStatusBar( + appState = viewModel.publicAppState, + isShowBackIcon = false + ) + } + }, + backgroundColor = Color(0xFFF5F5F5), + showBackIcon = false + ) { paddingValues -> + // 设置项列表 - 可滚动 + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + // 设置项容器 - 白色圆角卡片 + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ) + ) { + Column { + // 设备配置 + SettingItem( + title = "设备配置", + onClick = { viewModel.onDeviceConfigClick() } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 打印机配置 + SettingItem( + title = "打印机配置", + onClick = { viewModel.onPrinterConfigClick() } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 技术支持 + SettingItem( + title = "技术支持", + rightContent = { + Text( + text = viewModel.techSupportPhone.collectAsState().value, + fontSize = 14.sp, + color = Color.Black + ) + } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 帮助中心 + SettingItem( + title = "帮助中心", + onClick = { viewModel.onHelpCenterClick() } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 帮助中心 + SettingItem( + title = "关于我们", + onClick = { viewModel.onAboutUsClick() } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // APP升级 + SettingItem( + title = "APP升级", + rightContent = { + Text( + text = "升级", + fontSize = 14.sp, + color = Color(0xFF2196F3), // 蓝色 + fontWeight = FontWeight.Medium + ) + }, + onClick = { viewModel.onAppUpgradeClick() } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 是否使用LAN + val useLan by viewModel.useLan.collectAsState() + SettingItem( + title = "是否用局域网连接盒子", + rightContent = { + Switch( + checked = useLan, + onCheckedChange = { viewModel.onUseLanChanged(it) } + ) + } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 打印纸余量 + val remainingPaperNum by viewModel.remainingPaperNum.collectAsState() + SettingItem( + title = "打印纸余量", + rightContent = { + Text( + text = "${remainingPaperNum}张", + fontSize = 14.sp, + color = Color.Black + ) + }, + onClick = { viewModel.onPaperNumClick() } + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 系统版本号 + SettingItem( + title = "系统版本号", + rightContent = { + Text( + text = BuildConfig.VERSION_NAME, + fontSize = 14.sp, + color = Color.Black + ) + } + ) + } + } + + // 退出APP按钮 + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + val activity = context.findActivity() + activity?.finishAffinity() + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF4444) // 红色按钮 + ) + ) { + Text( + text = "退出APP", + fontSize = 16.sp, + color = Color.White, + fontWeight = FontWeight.Medium + ) + } + } + } + + // 打印纸张数编辑对话框 + val showPaperNumDialog by viewModel.showPaperNumDialog.collectAsState() + val remainingPaperNum by viewModel.remainingPaperNum.collectAsState() + if (showPaperNumDialog) { + PaperNumInputDialog( + initialValue = remainingPaperNum, + onDismiss = { viewModel.hidePaperNumDialog() }, + onConfirm = { num -> viewModel.savePaperNum(num) } + ) + } + + // 版本更新对话框 + if (showUpdateDialog && latestVersion != null) { + UpdateDialog( + versionResponse = latestVersion!!, + currentVersionName = currentVersionName, + isForceUpdate = isForceUpdate, + downloadProgress = downloadProgress, + isDownloading = isDownloading, + onDismiss = { viewModel.versionUpdateManager.dismissUpdateDialog() }, + onUpdate = { viewModel.versionUpdateManager.startDownload(viewModel.viewModelScope) } + ) + } +} + +@Composable +fun SettingItem( + title: String, + rightContent: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + } + ) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + fontSize = 15.sp, + color = Color(0xFF666666), + fontWeight = FontWeight.Normal + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + rightContent?.invoke() + + if (onClick != null) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = Color(0xFFCCCCCC), + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true, name = "设置页面预览") +@Composable +fun SettingScreenPreview() { + MaterialTheme { + AppScaffold( + topBar = { + AppTitleBar( + backgroundColor = Color.White, + title = "设置", + isShowBackIcon = true, + onBackClick = {}, + isShowButtonLine = true + ) + }, + backgroundColor = Color(0xFFF5F5F5), + showBackIcon = false + ) { paddingValues -> + // 设置项列表 + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + // 设置项容器 - 白色圆角卡片 + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ) + ) { + Column { + // 设备配置 + SettingItem( + title = "设备配置", + onClick = {} + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 打印机配置 + SettingItem( + title = "打印机配置", + onClick = {} + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 技术支持 + SettingItem( + title = "技术支持", + rightContent = { + Text( + text = "13112345678", + fontSize = 14.sp, + color = Color.Black + ) + } + ) + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 帮助中心 + SettingItem( + title = "帮助中心", + onClick = {} + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // APP升级 + SettingItem( + title = "APP升级", + rightContent = { + Text( + text = "升级", + fontSize = 14.sp, + color = Color(0xFF2196F3), + fontWeight = FontWeight.Medium + ) + }, + onClick = {} + ) + + Divider( + color = Color(0xFFEEEEEE), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // 系统版本号 + SettingItem( + title = "系统版本号", + rightContent = { + Text( + text = "1.0.1", + fontSize = 14.sp, + color = Color.Black + ) + } + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/AgreementViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/AgreementViewModel.kt new file mode 100644 index 0000000..71d29ac --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/AgreementViewModel.kt @@ -0,0 +1,68 @@ +package com.yzx.kiosk.ui.setting.viewmodel + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import javax.inject.Inject + + + +@HiltViewModel +class AgreementViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val repository: NetWorkRepository, + savedStateHandle: SavedStateHandle, + private val appStore: AppStoreDataSource, + @ApplicationContext private val context: Context, +): BaseViewModel( + navigator = navigator, + appState = appState +) +{ + + + // 从导航参数获取并解码 + private val agreementUrl: String = run { + val url = savedStateHandle.get(AgreementRoutes.AGREEMENT_URL) ?: "" + try { + URLDecoder.decode(url, StandardCharsets.UTF_8.name()) + } catch (e: Exception) { + url // 如果解码失败,返回原始值 + } + } + private val agreementName: String = run { + val name = savedStateHandle.get(AgreementRoutes.AGREEMENT_NAME) ?: "" + try { + URLDecoder.decode(name, StandardCharsets.UTF_8.name()) + } catch (e: Exception) { + name // 如果解码失败,返回原始值 + } + } + + + private val _agreementName = MutableStateFlow(agreementName) + val title: StateFlow = _agreementName + + private val _agreementUrl = MutableStateFlow(agreementUrl) + val url: StateFlow = _agreementUrl + + + + + + + // 从导航参数中获取 URL + //val url = savedStateHandle.get("url") ?: "" +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/SettingViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/SettingViewModel.kt new file mode 100644 index 0000000..1b9cdce --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/SettingViewModel.kt @@ -0,0 +1,98 @@ +package com.yzx.kiosk.ui.setting.viewmodel + +import androidx.lifecycle.viewModelScope +import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes +import com.yzx.kiosk.utils.VersionUpdateManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class SettingViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val appStoreDataSource: AppStoreDataSource, + val versionUpdateManager: VersionUpdateManager +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + // 技术支持电话 + val techSupportPhone: StateFlow = MutableStateFlow( + appStoreDataSource.getHomepageServicePhone().ifEmpty { "" } + ).asStateFlow() + + // 是否使用LAN + private val _useLan = MutableStateFlow(appStoreDataSource.getUseLan()) + val useLan: StateFlow = _useLan.asStateFlow() + + // 打印纸张数 + private val _remainingPaperNum = MutableStateFlow(appStoreDataSource.getRemainingPaperNum()) + val remainingPaperNum: StateFlow = _remainingPaperNum.asStateFlow() + + // 是否显示打印纸张数编辑对话框 + private val _showPaperNumDialog = MutableStateFlow(false) + val showPaperNumDialog: StateFlow = _showPaperNumDialog.asStateFlow() + + fun onUseLanChanged(useLan: Boolean) { + appStoreDataSource.saveUseLan(useLan) + // 更新状态 + _useLan.value = useLan + } + + fun onPaperNumClick() { + _showPaperNumDialog.value = true + } + + fun hidePaperNumDialog() { + _showPaperNumDialog.value = false + } + + fun savePaperNum(num: Int) { + appStoreDataSource.saveRemainingPaperNum(num) + _remainingPaperNum.value = num + // 同步更新全局状态,以便首页实时更新 + appState.updateRemainingPaperNum(num) + _showPaperNumDialog.value = false + } + + fun onDeviceConfigClick() { + toPage(AppRoutes.DEVICE_CONFIG) + } + + fun onPrinterConfigClick() { + toPage(AppRoutes.PRINTER_MANAGE) + } + + fun onHelpCenterClick() { + val args = mapOf( + AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/help-center.html", + AgreementRoutes.AGREEMENT_NAME to "帮助中心", + ) + + toPage(AppRoutes.AGREEMENT, args) + } + + fun onAboutUsClick() { + val args = mapOf( + AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/about-us.html", + AgreementRoutes.AGREEMENT_NAME to "关于我们", + ) + + toPage(AppRoutes.AGREEMENT, args) + } + + fun onAppUpgradeClick() { + // 检查版本更新(显示Toast提示) + versionUpdateManager.checkVersionUpdate(viewModelScope, showToast = true) + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PaySuccessScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PaySuccessScreen.kt new file mode 100644 index 0000000..2c98968 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PaySuccessScreen.kt @@ -0,0 +1,338 @@ +package com.yzx.kiosk.ui.upload.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import com.yzx.kiosk.R +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import com.yzx.kiosk.component.appbar.AppTitleBarNoStatusBarPadding +import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.upload.viewmodel.PaySuccessViewModel + +/** 与心跳动画 target一致;槽位高度 = 72.dp * 该值,避免放大时盖住下方区域 */ +private const val PRINT_BTN_HEARTBEAT_MAX_SCALE = 1.09f + +private fun isValidRemoteImageUrl(url: String): Boolean { + val t = url.trim() + if (t.isEmpty()) return false + return t.startsWith("http://", ignoreCase = true) || + t.startsWith("https://", ignoreCase = true) +} + +/** 按钮左右缩进,宽度变短;同等缩放比例下横向多出的像素更少,不易贴边/溢出 */ +private val PRINT_BTN_HORIZONTAL_INSET = 20.dp + +/** + * 心跳 `tween` 的缓动曲线(两头「略缓」强弱可在这里换): + * - [FastOutSlowInEasing]:Material 默认,起止略慢、中间略快 + * - LinearEasing:匀速,无额外缓急(需自行 `import …LinearEasing` 并赋值) + * - [CubicBezierEasing]:自定义贝塞尔;(x1,y1) 靠近起点、(x2,y2) 靠近终点,数值越大往往起止越「肉」、需边调边看 + */ +private val PRINT_BTN_HEARTBEAT_EASING: Easing = LinearEasing +// private val PRINT_BTN_HEARTBEAT_EASING = CubicBezierEasing(0.45f, 0f, 0.55f, 1f) // 示例:两头再柔和一点 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PaySuccessScreen( + photoUrls: List, + orderNumber: String = "", + captureType: Int? = null, + viewModel: PaySuccessViewModel = hiltViewModel() +) { + val photoList by viewModel.photoList.collectAsState() + val selectedPhotoIds by viewModel.selectedPhotoIds.collectAsState() + val buttonPrintUrl by viewModel.buttonPrintUrl.collectAsState() + val printBtnRemoteUrl = buttonPrintUrl.trim() + val useRemotePrintButton = isValidRemoteImageUrl(printBtnRemoteUrl) + + // 初始化照片列表和订单信息 + LaunchedEffect(photoUrls, orderNumber, captureType) { + viewModel.initPhotoList(photoUrls, orderNumber, captureType) + } + + val selectedCount = selectedPhotoIds.size + val isAllSelected = photoList.isNotEmpty() && selectedPhotoIds.size == photoList.size + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBarNoStatusBarPadding( + backgroundColor = Color(0xFF4CAF50), // 绿色 + title = "支付成功", + isWhite = true, + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = false + ) + FaceBarNoStatusBarPadding( + title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理" + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + // 打印选中照片按钮:心跳缩放走 graphicsLayer;外层槽高为 72dp×最大缩放,预留上下空间,不压盖下方 + val heartbeatTransition = rememberInfiniteTransition(label = "printBtnHeartbeat") + val pulseScale by heartbeatTransition.animateFloat( + initialValue = 1f, + targetValue = PRINT_BTN_HEARTBEAT_MAX_SCALE, + animationSpec = infiniteRepeatable( + animation = tween(600, easing = PRINT_BTN_HEARTBEAT_EASING), + repeatMode = RepeatMode.Reverse + ), + label = "pulseScale" + ) + val drawScale = if (selectedCount > 0) pulseScale else 1f + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .height(72.dp * PRINT_BTN_HEARTBEAT_MAX_SCALE), + contentAlignment = Alignment.Center + ) { + Button( + onClick = { viewModel.onPrintClick() }, + modifier = Modifier + .padding(horizontal = PRINT_BTN_HORIZONTAL_INSET) + .fillMaxWidth() + .height(72.dp) + .graphicsLayer { + scaleX = drawScale + scaleY = drawScale + transformOrigin = TransformOrigin.Center + }, + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)), + enabled = selectedCount > 0 + ) { +// Row( +// modifier = Modifier.fillMaxWidth(), +// horizontalArrangement = Arrangement.Center, +// verticalAlignment = Alignment.CenterVertically +// ) { +// Text( +// text = "选中照片点击打印", +// fontSize = 24.sp, +// color = Color.White, +// fontWeight = FontWeight.Bold +// ) +// Spacer(modifier = Modifier.width(6.dp)) + if (useRemotePrintButton) { + AsyncImage( + model = printBtnRemoteUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + placeholder = painterResource(id = R.mipmap.btn_pay_success), + error = painterResource(id = R.mipmap.btn_pay_success) + ) + } else { + Icon( + painter = painterResource(id = R.mipmap.btn_pay_success), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.fillMaxSize() + ) + } +// } + } + } + + // 全选行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.clickable { viewModel.toggleSelectAll() }, + verticalAlignment = Alignment.CenterVertically + ) { + // 复选框 + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (isAllSelected) Color(0xFF2196F3) else Color.White) + .border( + width = 2.dp, + color = if (isAllSelected) Color(0xFF2196F3) else Color(0xFFCCCCCC), + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + if (isAllSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(18.dp)) + + Text( + text = "全选", + fontSize = 24.sp, + color = Color.Black + ) + Text( + text = "(共${photoList.size}张)", + fontSize = 24.sp, + color = Color.Black + ) + } + + // 已选择数量 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "已选择", + fontSize = 24.sp, + color = Color.Black + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = selectedCount.toString(), + fontSize = 28.sp, + fontWeight = FontWeight.Medium, + color = Color(0xFF2196F3) + ) + Text( + text = "张", + fontSize = 24.sp, + color = Color.Black + ) + } + } + + Spacer(modifier = Modifier.height(18.dp)) + + // 图片瀑布流列表 + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp), + verticalItemSpacing = 18.dp + ) { + itemsIndexed(photoList) { index, photo -> + val isSelected = selectedPhotoIds.contains(photo.id) + PaySuccessPhotoItem( + photoUrl = photo.url, + aspectRatio = photo.aspectRatio, + isSelected = isSelected, + onToggleSelection = { viewModel.togglePhotoSelection(photo.id) } + ) + } + } + } + } +} + +@Composable +fun PaySuccessPhotoItem( + photoUrl: String, + aspectRatio: Float, + isSelected: Boolean, + onToggleSelection: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) // 根据原比例计算高度 + .clip(RoundedCornerShape(18.dp)) + .clickable { onToggleSelection() } + ) { + // 图片 - 按原比例显示 + AsyncImage( + model = photoUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + + // 选中遮罩 + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent + ) + ) + + // 右上角复选框 + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(18.dp) + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (isSelected) Color(0xFF2196F3) else Color.Black.copy(alpha = 0.5f)) + .border( + width = 2.dp, + color = if (isSelected) Color(0xFF2196F3) else Color(0xFF666666), + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoPreviewDialog.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoPreviewDialog.kt new file mode 100644 index 0000000..ba58bee --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoPreviewDialog.kt @@ -0,0 +1,322 @@ +package com.yzx.kiosk.ui.upload.view + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import com.yzx.kiosk.utils.LogUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * 图片预览弹框 - 全屏展示,仅预览已选择的图片 + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PhotoPreviewDialog( + photoList: List, + initialIndex: Int, + onDismiss: () -> Unit +) { + val pagerState = rememberPagerState( + initialPage = initialIndex, + pageCount = { photoList.size } + ) + val coroutineScope = rememberCoroutineScope() + val currentIndex = pagerState.currentPage + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Column ( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 顶部:页码指示和关闭按钮 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp, start = 24.dp, end = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 占位,保持布局平衡 + Spacer(modifier = Modifier.size(32.dp)) + + // 页码指示 - 居中 + Text( + text = "${currentIndex + 1}/${photoList.size}", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + // 关闭按钮 - 右上角 + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(color = Color.White) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "关闭", + tint = Color.Black.copy(alpha = 0.3f), + modifier = Modifier.size(32.dp) + ) + } + } + + // 中间内容区域 - 白色背景 + Column( + modifier = Modifier + .fillMaxWidth() + .padding( 24.dp) + .clip(RoundedCornerShape(18.dp)) + .background(Color.White) + ) { + Spacer(modifier = Modifier.height(36.dp)) + + // 上一张/下一张按钮 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // 上一张按钮 + Button( + onClick = { + if (currentIndex > 0) { + coroutineScope.launch { + pagerState.animateScrollToPage(currentIndex - 1) + } + } + }, + enabled = currentIndex > 0, + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3), + disabledContainerColor = Color(0xFFF4F4F4) + ), + modifier = Modifier.width(111.dp).height(56.dp) + ) { + Text( + text = "上一张", + fontSize = 21.sp, + color = if (currentIndex > 0) Color.White else Color(0xFF999999), + fontWeight = FontWeight.Bold) + } + + // 下一张按钮 + Button( + onClick = { + if (currentIndex < photoList.size - 1) { + coroutineScope.launch { + pagerState.animateScrollToPage(currentIndex + 1) + } + } + }, + enabled = currentIndex < photoList.size - 1, + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2196F3), + disabledContainerColor = Color(0xFFF4F4F4) + ), + modifier = Modifier.width(111.dp).height(56.dp) + ) { + Text( + text = "下一张", + fontSize = 21.sp, + color = if (currentIndex < photoList.size - 1) Color.White else Color(0xFF999999), + fontWeight = FontWeight.Bold) + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + // 图片轮播 + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp) + ) { page -> + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)), + contentAlignment = Alignment.Center + ) { + RetryableAsyncImage( + imageUrl = photoList[page], + modifier = Modifier.fillMaxWidth().background(color = Color.White, shape = RoundedCornerShape(18.dp)), + contentScale = ContentScale.Fit, + maxRetries = 10 + ) + } + } + + Spacer(modifier = Modifier.height(36.dp)) + } + } + } +} + +/** + * 带重试功能的图片加载组件 + * @param imageUrl 图片URL + * @param modifier 修饰符 + * @param contentScale 内容缩放模式 + * @param maxRetries 最大重试次数,默认10次 + */ +@Composable +fun RetryableAsyncImage( + imageUrl: String, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + maxRetries: Int = 10 +) { + // 重试逻辑:跟踪重试次数和当前加载的URL(用于强制重新加载) + var retryCount by remember(imageUrl) { mutableIntStateOf(0) } + var currentImageUrl by remember(imageUrl) { mutableStateOf(imageUrl) } + var lastErrorState by remember(imageUrl) { mutableStateOf(null) } + + SubcomposeAsyncImage( + model = currentImageUrl, + contentDescription = null, + modifier = modifier.background(color = Color.White, shape = RoundedCornerShape(18.dp)), + contentScale = contentScale + ) { + when (val imageState = painter.state) { + is coil.compose.AsyncImagePainter.State.Loading -> { + // 加载中显示占位符 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color(0xFF2196F3), + strokeWidth = 2.dp + ) + } + } + is coil.compose.AsyncImagePainter.State.Error -> { + // 加载失败,尝试重试 + // 只有当错误状态发生变化时才触发重试,避免重复触发 + LaunchedEffect(imageState.result.throwable) { + // 检查是否是新的错误状态(避免重复处理同一个错误) + val errorThrowable = imageState.result.throwable + if (lastErrorState?.result?.throwable != errorThrowable && retryCount < maxRetries) { + lastErrorState = imageState + retryCount++ + LogUtils.d("PhotoSelectScreen", "图片加载失败,重试 $retryCount/$maxRetries - URL: $imageUrl") + + // 延迟后重试(给网络一些恢复时间,递增延迟) + delay(500L * retryCount) // 递增延迟:500ms, 1000ms, 1500ms... + + // 通过添加时间戳参数来强制 Coil 重新加载 + currentImageUrl = "$imageUrl?retry=$retryCount&t=${System.currentTimeMillis()}" + } else if (retryCount >= maxRetries) { + LogUtils.e("PhotoSelectScreen", "图片加载失败,已达到最大重试次数 $maxRetries - URL: $imageUrl") + } + } + + // 显示加载失败占位符 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + if (retryCount >= maxRetries) { + // 达到最大重试次数,显示失败提示 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "加载失败", + fontSize = 12.sp, + color = Color(0xFF999999) + ) + } + } else { + // 正在重试,显示加载指示器 + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color(0xFF2196F3), + strokeWidth = 2.dp + ) + } + } + } + else -> { + // 加载成功,重置重试计数和错误状态 + LaunchedEffect(imageState) { + if (retryCount > 0 || lastErrorState != null) { + LogUtils.d("PhotoSelectScreen", "图片加载成功,重置重试计数 - URL: $imageUrl") + retryCount = 0 + lastErrorState = null + currentImageUrl = imageUrl // 恢复原始URL + } + } + SubcomposeAsyncImageContent() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt new file mode 100644 index 0000000..7ab13ec --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt @@ -0,0 +1,550 @@ +package com.yzx.kiosk.ui.upload.view + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.AsyncImage +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.R +import com.yzx.kiosk.ui.common.dialog.PaperLowWarningDialog +import com.yzx.kiosk.ui.common.dialog.shouldWarnLowPrintPaper +import com.yzx.kiosk.ui.upload.viewmodel.PhotoSelectViewModel +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.QrCodeUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun PhotoSelectScreen( + photoUrls: List = emptyList(), + viewModel: PhotoSelectViewModel = hiltViewModel() +) { + // 如果传入了URL列表,初始化图片列表 + LaunchedEffect(photoUrls) { + if (photoUrls.isNotEmpty()) { + viewModel.initPhotoList(photoUrls) + } + } + + val photoList by viewModel.photoList.collectAsState() + val selectedPhotoIds by viewModel.selectedPhotoIds.collectAsState() + val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() // 从接口获取的单价(字符串) + val totalPrice by viewModel.totalPrice.collectAsState() // 从接口获取的总价(字符串) + + val selectedCount = selectedPhotoIds.size + val isAllSelected = photoList.isNotEmpty() && selectedPhotoIds.size == photoList.size + + // 预览弹框状态 + var showPreviewDialog by remember { mutableStateOf(false) } + var previewInitialIndex by remember { mutableIntStateOf(0) } + + // 支付弹框状态 + var showPayDialog by remember { mutableStateOf(false) } + val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState() + val remainingPaperNum by viewModel.publicAppState.remainingPaperNum.collectAsState() + val homepageServicePhone by viewModel.homepageServicePhone.collectAsState() + + var paperLowWarningDismissed by remember { mutableStateOf(false) } + LaunchedEffect(remainingPaperNum) { + if (!shouldWarnLowPrintPaper(remainingPaperNum)) { + paperLowWarningDismissed = false + } + } + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBar( + backgroundColor = Color.White, + title = "上传照片打印", + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = false + ) + FaceBarNoStatusBarPadding( + title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理" + ) + } + }, + backgroundColor = Color(0xFFF5F5F5) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .background(color = Color.White) + .padding(bottom = 12.dp) + ) { + // 顶部选择信息和支付按钮 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "已选择", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = selectedCount.toString(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0073FF) + ) + Text( + text = "张", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = "打印${pricePerPhoto}元/张", + fontSize = 21.sp, + color = Color(0xFF000000) + ) + } + + Button( + onClick = { + viewModel.getPayUrl { url -> + showPayDialog = true + } + }, + modifier = Modifier + .height(72.dp) + .widthIn(min = 180.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)), + enabled = selectedCount > 0 + ) { + Text( + text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", + fontSize = 24.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + } + + // 全选行 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { viewModel.toggleSelectAll() }, + verticalAlignment = Alignment.CenterVertically + ) { + // 复选框 + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (isAllSelected) Color(0xFF0073FF) else Color.White) + .border( + width = 2.dp, + color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC), + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + if (isAllSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(18.dp)) + + Text( + text = "全选", + fontSize = 24.sp, + color = Color.Black + ) + Text( + text = "(共${photoList.size}张)", + fontSize = 24.sp, + color = Color.Black + ) + } + + // 图片瀑布流列表 + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 22.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp), + verticalItemSpacing = 18.dp + ) { + itemsIndexed(photoList) { index, photo -> + val isSelected = selectedPhotoIds.contains(photo.id) + PhotoItem( + photoUrl = photo.url, + aspectRatio = photo.aspectRatio, + isSelected = isSelected, + onToggleSelection = { viewModel.togglePhotoSelection(photo.id) }, + onPreviewClick = { + // 计算在已选列表中的索引 + val selectedList = photoList.filter { selectedPhotoIds.contains(it.id) } + val selectedIndex = selectedList.indexOfFirst { it.id == photo.id } + if (selectedIndex >= 0) { + previewInitialIndex = selectedIndex + showPreviewDialog = true + } + } + ) + } + } + } + } + + if (shouldWarnLowPrintPaper(remainingPaperNum) && !paperLowWarningDismissed) { + PaperLowWarningDialog( + remainingSheets = remainingPaperNum, + servicePhone = homepageServicePhone, + onDismiss = { paperLowWarningDismissed = true } + ) + } + + // 预览弹框 - 仅预览已选择的图片 + val selectedPhotoUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url } + if (showPreviewDialog && selectedPhotoUrls.isNotEmpty()) { + PhotoPreviewDialog( + photoList = selectedPhotoUrls, + initialIndex = previewInitialIndex, + onDismiss = { showPreviewDialog = false } + ) + } + + // 支付弹框 + if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { + val selectedUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url } + WechatPayDialog( + qrCodeUrl = payQrCodeUrl!!, + totalPrice = if (totalPrice != "--") totalPrice else "0", + onDismiss = { showPayDialog = false }, + onPaySuccess = { + showPayDialog = false + // 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面 + // 跳转逻辑在 PhotoSelectViewModel 的 navigateToPaySuccess 方法中处理 + } + ) + } +} + +@Composable +fun PhotoItem( + photoUrl: String, + aspectRatio: Float, + isSelected: Boolean, + onToggleSelection: () -> Unit, + onPreviewClick: () -> Unit +) { + // 重试逻辑:跟踪重试次数和当前加载的URL(用于强制重新加载) + var retryCount by remember(photoUrl) { mutableIntStateOf(0) } + var currentImageUrl by remember(photoUrl) { mutableStateOf(photoUrl) } + var lastErrorState by remember(photoUrl) { mutableStateOf(null) } + val maxRetries = 10 + + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(aspectRatio) + .clip(RoundedCornerShape(18.dp)) + .clickable { onToggleSelection() } + ) { + // 图片 - 使用 SubcomposeAsyncImage 来检测加载状态和错误 + SubcomposeAsyncImage( + model = currentImageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) { + when (val imageState = painter.state) { + is coil.compose.AsyncImagePainter.State.Loading -> { + // 加载中显示占位符 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color(0xFF2196F3), + strokeWidth = 2.dp + ) + } + } + is coil.compose.AsyncImagePainter.State.Error -> { + // 加载失败,尝试重试 + // 只有当错误状态发生变化时才触发重试,避免重复触发 + LaunchedEffect(imageState.result.throwable) { + // 检查是否是新的错误状态(避免重复处理同一个错误) + val errorThrowable = imageState.result.throwable + if (lastErrorState?.result?.throwable != errorThrowable && retryCount < maxRetries) { + lastErrorState = imageState + retryCount++ + LogUtils.d("PhotoSelectScreen", "图片加载失败,重试 $retryCount/$maxRetries - URL: $photoUrl") + + // 延迟后重试(给网络一些恢复时间,递增延迟) + delay(500L * retryCount) // 递增延迟:500ms, 1000ms, 1500ms... + + // 通过添加时间戳参数来强制 Coil 重新加载 + currentImageUrl = "$photoUrl?retry=$retryCount&t=${System.currentTimeMillis()}" + } else if (retryCount >= maxRetries) { + LogUtils.e("PhotoSelectScreen", "图片加载失败,已达到最大重试次数 $maxRetries - URL: $photoUrl") + } + } + + // 显示加载失败占位符 + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + if (retryCount >= maxRetries) { + // 达到最大重试次数,显示失败提示 + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "加载失败", + fontSize = 12.sp, + color = Color(0xFF999999) + ) + } + } else { + // 正在重试,显示加载指示器 + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color(0xFF2196F3), + strokeWidth = 2.dp + ) + } + } + } + else -> { + // 加载成功,重置重试计数和错误状态 + LaunchedEffect(imageState) { + if (retryCount > 0 || lastErrorState != null) { + LogUtils.d("PhotoSelectScreen", "图片加载成功,重置重试计数 - URL: $photoUrl") + retryCount = 0 + lastErrorState = null + currentImageUrl = photoUrl // 恢复原始URL + } + } + SubcomposeAsyncImageContent() + } + } + } + + // 选中遮罩 + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent + ) + ) + + // 右上角复选框 + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (isSelected) Color(0xFF0073FF) else Color.Black.copy(alpha = 0.5f)) + .border( + width = 2.dp, + color = if (isSelected) Color(0xFF0073FF) else Color(0xFF666666), + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + // 点击预览文字(选中时显示)- 点击打开预览弹框 + if (isSelected) { + Text( + text = "点击预览", + fontSize = 24.sp, + color = Color.White, + modifier = Modifier + .align(Alignment.Center) + .clickable { onPreviewClick() } + ) + } + } +} + +/** + * 微信支付弹框 + */ +@Composable +fun WechatPayDialog( + qrCodeUrl: String, + totalPrice: String, + onDismiss: () -> Unit, + onPaySuccess: () -> Unit +) { + // 使用接口返回的URL生成支付二维码 + val qrCodeBitmap = remember(qrCodeUrl) { + QrCodeUtils.generateStyledQrBitmap( + content = qrCodeUrl, + size = 600, + qrColor = android.graphics.Color.BLACK, + bgColor = android.graphics.Color.WHITE, + cornerRadius = 0f + ) + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.85f) + .clip(RoundedCornerShape(18.dp)) + .background(Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 标题和关闭按钮 + Box( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "微信支付", + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.align(Alignment.Center) + ) + + // 关闭按钮 + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(36.dp) + .clip(CircleShape) + .background(Color(0xFF999999)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "关闭", + tint = Color(0xFFFFFFFF), + modifier = Modifier.size(32.dp) + ) + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + // 二维码 + Box( + modifier = Modifier + .size(220.dp) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = qrCodeBitmap.asImageBitmap(), + contentDescription = "支付二维码", + modifier = Modifier.fillMaxSize() + ) + } + + Spacer(modifier = Modifier.height(22.dp)) + + // 提示文字 + Text( + text = "请使用微信扫描二维码支付", + fontSize = 21.sp, + color = Color(0xFF000000) + ) + } + } + } +} + + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PrintingScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PrintingScreen.kt new file mode 100644 index 0000000..160ea1e --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PrintingScreen.kt @@ -0,0 +1,404 @@ +package com.yzx.kiosk.ui.upload.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import android.widget.ImageView +import androidx.compose.foundation.layout.Arrangement +import com.bumptech.glide.Glide +import com.yzx.kiosk.R +import com.yzx.kiosk.component.appbar.AppTitleBarNoStatusBarPadding +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.theme.TITLE_BAR_HEIGHT +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.upload.viewmodel.PrintingViewModel +import com.yzx.kiosk.utils.QrCodeUtils +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrintingScreen( + photoUrls: List, + orderNumber: String = "", + captureType: Int? = null, + viewModel: PrintingViewModel = hiltViewModel() +) { + val isPrinting by viewModel.isPrinting.collectAsState() + val countdown by viewModel.countdown.collectAsState() + val estimatedTime by viewModel.estimatedTime.collectAsState() + val qrCodeUrl by viewModel.qrCodeUrl.collectAsState() + val hotline by viewModel.hotline.collectAsState() + + // 初始化并开始打印 + LaunchedEffect(photoUrls, orderNumber, captureType) { + viewModel.startPrinting(photoUrls, orderNumber, captureType) + } + + // 生成电子版照片二维码 + val qrCodeBitmap = remember(qrCodeUrl) { + if (qrCodeUrl.isNotEmpty()) { + QrCodeUtils.generateStyledQrBitmap( + content = qrCodeUrl, + size = 800, + qrColor = android.graphics.Color.BLACK, + bgColor = android.graphics.Color.WHITE, + cornerRadius = 0f + ) + } else null + } + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBarNoStatusBarPadding( + backgroundColor = if (isPrinting) Color(0xFF2196F3) else Color(0xFF4CAF50), + title = if (isPrinting) "打印中" else "打印完成", + isWhite = true, + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = false + ) + SharedPrintingStatusBar( + appState = viewModel.publicAppState, + isShowBackIcon = false + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(36.dp)) + + if (isPrinting) { + // 打印中状态 - 动态显示三个点 + var dotCount by remember { mutableStateOf(1) } + + // 循环更新点的数量:1个 -> 2个 -> 3个 -> 1个... + LaunchedEffect(isPrinting) { + while (isPrinting) { + delay(500) // 每500ms更新一次 + dotCount = when (dotCount) { + 1 -> 2 + 2 -> 3 + else -> 1 + } + } + } + + val dotsText = when (dotCount) { + 1 -> "." + 2 -> ".." + else -> "..." + } + + Text( + text = "打印中$dotsText", + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 预计等待时间 - 倒计时显示 + val displayTime = if (estimatedTime <= 0) 0 else estimatedTime + Text( + text = buildAnnotatedString { + append("您的照片正在打印中,预计等待时间") + withStyle(style = SpanStyle(color = Color(0xFF2196F3), fontWeight = FontWeight.Bold)) { + append("${displayTime}秒") + } + }, + fontSize = 22.sp, + color = Color(0xFF666666) + ) + } else { + // 打印完成状态 + Text( + text = buildAnnotatedString { + append("返回倒计时") + withStyle(style = SpanStyle(color = Color(0xFF0073FF), fontWeight = FontWeight.Bold)) { + append("${countdown}秒") + } + }, + fontSize = 21.sp, + color = Color.Black + ) + } + + Spacer(modifier = Modifier.height(36.dp)) + + if (isPrinting) { + // 打印中 - 二维码卡片 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 二维码 + if (qrCodeBitmap != null) { + Box( + modifier = Modifier + .size(210.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = qrCodeBitmap.asImageBitmap(), + contentDescription = "电子版照片二维码", + modifier = Modifier.fillMaxSize() + ) + } + } else { + Box( + modifier = Modifier + .size(210.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + Text( + text = "加载中...", + fontSize = 22.sp, + color = Color(0xFF999999) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 提示文字 + Text( + text = buildAnnotatedString { + append("请使用微信扫描获取") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("电子版照片") + } + }, + fontSize = 22.sp, + color = Color.Black + ) + } + } + } else { + // 打印完成 - 设备图片和二维码并排 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(36.dp) + ) { + // 设备图片 + Card( + modifier = Modifier + .weight(1f) + .aspectRatio(0.65f), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5)) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + // 使用本地 GIF 动图资源 + val context = LocalContext.current + AndroidView( + factory = { ctx -> + ImageView(ctx).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + Glide.with(ctx) + .asGif() + .load(R.mipmap.res_printend) + .into(this) + } + }, + modifier = Modifier.fillMaxSize().background(shape = RoundedCornerShape(12.dp), color = Color(0xFFF5F5F5)), + ) + } + } + + // 二维码 + Card( + modifier = Modifier + .weight(1f) + .aspectRatio(0.65f), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5)) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 二维码 + if (qrCodeBitmap != null) { + Box( + modifier = Modifier + .size(220.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier + .size(204.dp), + bitmap = qrCodeBitmap.asImageBitmap(), + contentDescription = "电子版照片二维码" + ) + } + } else { + Box( + modifier = Modifier + .size(220.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFF5F5F5)), + contentAlignment = Alignment.Center + ) { + Text( + text = "加载中...", + fontSize = 22.sp, + color = Color(0xFF999999) + ) + } + } + + Spacer(modifier = Modifier.height(36.dp)) + + Text( + text = "请使用微信扫描", + fontSize = 21.sp, + color = Color.Black + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = buildAnnotatedString { + append("获取") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("电子版照片") + } + }, + fontSize = 21.sp, + color = Color.Black + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 重要提示卡片 + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF0E2)) // 浅橙色背景 + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "重要提示", + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF7B00) + ) + + Spacer(modifier = Modifier.height(18.dp)) + + Text( + text = "如果打印失败,可以联系客服", + fontSize = 21.sp, + color = Color(0xFFFF7B00) + ) + Text( + text = "手机号:${hotline}", + fontSize = 21.sp, + color = Color(0xFFFF7B00) + ) + + Spacer(modifier = Modifier.height(18.dp)) + + Text( + text = "每张照片预计打印时间为15秒,如果需要免费塑封,裁剪或付费相框,请到柜台咨询", + fontSize = 21.sp, + color = Color(0xFFFF7B00) + ) + } + } + + Spacer(modifier = Modifier.height(21.dp)) + + // 向下箭头 + Icon( + painter = painterResource(R.mipmap.ic_get_photo), + contentDescription = null, + tint = Color.Red, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.weight(1f)) + + // 底部提示 + Box( + modifier = Modifier + .fillMaxWidth() + .height(TITLE_BAR_HEIGHT) + .background(Color(0xFFFF7B00)), + contentAlignment = Alignment.Center + ) { + Text( + text = "请在屏幕下方拿取照片", + fontSize = 27.sp, + color = Color.White, + fontWeight = FontWeight.Medium + ) + } + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/UploadPhotoScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/UploadPhotoScreen.kt new file mode 100644 index 0000000..4950291 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/UploadPhotoScreen.kt @@ -0,0 +1,319 @@ +package com.yzx.kiosk.ui.upload.view + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.yzx.kiosk.R +import com.yzx.kiosk.component.appbar.AppTitleBar +import com.yzx.kiosk.component.appbar.SharedPrintingStatusBar +import com.yzx.kiosk.component.scaffold.AppScaffold +import com.yzx.kiosk.ui.common.dialog.RefreshQrCodeDialog +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.upload.viewmodel.UploadPhotoViewModel +import com.yzx.kiosk.utils.QrCodeUtils +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UploadPhotoScreen( + viewModel: UploadPhotoViewModel = hiltViewModel() +) { + val qrCodeContent by viewModel.qrCodeContent.collectAsState() + val countdown by viewModel.countdown.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val photoSpec by viewModel.photoSpec.collectAsState() + val printPrice by viewModel.printPrice.collectAsState() + val estimatedTime by viewModel.estimatedTime.collectAsState() + val printTech by viewModel.printTech.collectAsState() + val servicePhone by viewModel.servicePhone.collectAsState() + val showScanSuccess by viewModel.showScanSuccess.collectAsState() + val showRefreshDialog by viewModel.showRefreshDialog.collectAsState() + + // 生成二维码 + val qrCodeBitmap = remember(qrCodeContent) { + if (qrCodeContent.isNotEmpty()) { + QrCodeUtils.generateStyledQrBitmap( + content = qrCodeContent, + size = 600, + qrColor = android.graphics.Color.BLACK, + bgColor = android.graphics.Color.WHITE, + cornerRadius = 0f + ) + } else null + } + + // 页面进入时加载二维码 + LaunchedEffect(Unit) { + viewModel.loadQrCodeUrl() + } + + FullScreenMode() + + AppScaffold( + topBar = { + Column { + AppTitleBar( + backgroundColor = Color.White, + title = "上传照片打印", + isShowBackIcon = true, + onBackClick = { viewModel.navigateBack() }, + isShowButtonLine = false + ) + SharedPrintingStatusBar( + appState = viewModel.publicAppState, + isShowBackIcon = false + ) + } + }, + backgroundColor = Color(0xFFF5F5F5) + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(24.dp)) + + // 二维码卡片 + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // 二维码图片 + Box( + modifier = Modifier + .size(240.dp) + .background(Color.White, RoundedCornerShape(36.dp)), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + CircularProgressIndicator() + } else { + qrCodeBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "二维码", + modifier = Modifier.fillMaxSize().padding(21.dp) + ) + } ?: CircularProgressIndicator() + } + + // 扫码成功蒙层 + if (showScanSuccess) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White.copy(alpha = 0.9f), RoundedCornerShape(36.dp)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // 成功图标(绿色圆圈 + 白色对勾) + Box( + modifier = Modifier + .size(80.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.mipmap.ic_scan_succes), + contentDescription = "Back", + modifier = Modifier.size(80.dp), + tint = Color.Unspecified + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + // 扫码成功文字 + Text( + text = "扫码成功", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 提示文字 + Text( + text = if (showScanSuccess) "请使用微信中选择账号登录" else "请使用微信扫描二维码上传照片", + fontSize = 21.sp, + fontWeight = FontWeight.Normal, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(6.dp)) + + // 警告文字 + Text( + text = "请确认二维码由本人手机扫描", + fontSize = 21.sp, + color = Color(0xFFFF7B00) + ) + Text( + text = "如非本人操作,请刷新二维码重新扫描", + fontSize = 21.sp, + color = Color(0xFFFF7B00) + ) + + Spacer(modifier = Modifier.height(18.dp)) + + // 倒计时 + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = countdown.toString(), + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF0073FF) + ) + Text( + text = "秒后自动返回", + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + color = Color.Black + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 刷新二维码按钮 + Button( + onClick = { viewModel.showRefreshDialog() }, + enabled = !showScanSuccess, // 扫码成功时禁用按钮 + modifier = Modifier + .fillMaxWidth() + .height(72.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF0073FF), + disabledContainerColor = Color(0xFFCCCCCC) // 禁用时的背景色 + ) + ) { + Text( + text = "刷新二维码", + fontSize = 24.sp, + color = Color.White // 禁用时的文字颜色 + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 打印服务信息卡片 + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "打印服务信息", + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 照片规格 + ServiceInfoRow(label = "照片规格", value = photoSpec) + + Spacer(modifier = Modifier.height(12.dp)) + + // 打印价格 + ServiceInfoRow( + label = "打印价格", + value = printPrice, + valueColor = Color(0xFFEF4444) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 预计耗时 + ServiceInfoRow(label = "预计耗时", value = estimatedTime) + + Spacer(modifier = Modifier.height(12.dp)) + + // 打印技术 + ServiceInfoRow(label = "打印技术", value = printTech) + + Spacer(modifier = Modifier.height(12.dp)) + + // 客服手机号 + ServiceInfoRow(label = "客服手机号", value = servicePhone) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + + // 刷新确认对话框 + if (showRefreshDialog) { + RefreshQrCodeDialog( + onDismiss = { viewModel.hideRefreshDialog() }, + onConfirm = { viewModel.refreshQrCode() } + ) + } +} + +@Composable +fun ServiceInfoRow( + label: String, + value: String, + valueColor: Color = Color.Black +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + fontSize = 22.sp, + color = Color(0xFF4B5563) + ) + Text( + text = value, + fontSize = 21.sp, + color = valueColor + ) + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt new file mode 100644 index 0000000..3726b31 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt @@ -0,0 +1,273 @@ +package com.yzx.kiosk.ui.upload.viewmodel + +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavOptions +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Size +import com.yzx.kiosk.App +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.utils.LogUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class PaySuccessViewModel @Inject constructor( + navigator: AppNavigator, + private val appStoreDataSource: AppStoreDataSource, + appState: AppState +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + // 图片列表(包含宽高比) + private val _photoList = MutableStateFlow>(emptyList()) + val photoList: StateFlow> = _photoList.asStateFlow() + + // 已选中的图片ID + private val _selectedPhotoIds = MutableStateFlow>(emptySet()) + val selectedPhotoIds: StateFlow> = _selectedPhotoIds.asStateFlow() + + // 订单号 + private val _orderNumber = MutableStateFlow("") + val orderNumber: StateFlow = _orderNumber.asStateFlow() + + // 抓拍类型 + private val _captureType = MutableStateFlow(null) + val captureType: StateFlow = _captureType.asStateFlow() + + /** 设备配置下发的打印按钮图 URL(可能为空) */ + private val _buttonPrintUrl = MutableStateFlow("") + val buttonPrintUrl: StateFlow = _buttonPrintUrl.asStateFlow() + + init { + _buttonPrintUrl.value = appStoreDataSource.getButtonPrintUrl() + } + + /** + * 初始化图片列表,默认全选 + * @param photoUrls 图片URL列表(JSON 格式或旧格式) + * @param orderNumber 订单号 + * @param captureType 抓拍类型 + * urls 格式: + * - JSON 格式(新):FileMapData 数组的 JSON 字符串 + * - id:url 格式(兼容):id:url,id:url + * - url 格式(兼容):url,url + */ + fun initPhotoList(photoUrls: List, orderNumber: String = "", captureType: Int? = null) { + // 保存订单信息 + _orderNumber.value = orderNumber + _captureType.value = captureType + LogUtils.d("PaySuccessViewModel", "初始化 - orderNumber: $orderNumber, captureType: $captureType") + + if (photoUrls.isEmpty()) { + _photoList.value = emptyList() + _selectedPhotoIds.value = emptySet() + return + } + + viewModelScope.launch { + var initialPhotos: List + + // 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串) + if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) { + // JSON 数组格式:将 file_map 列表序列化后的 JSON 数组字符串反序列化为 FileMapData 对象数组 + try { + val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8") + LogUtils.d("PaySuccessViewModel", "接收到 JSON 数组字符串: $jsonString") + + // 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组 + val type = object : TypeToken>() {}.type + val fileMapList: List = Gson().fromJson(jsonString, type) ?: emptyList() + LogUtils.d("PaySuccessViewModel", "反序列化成功,FileMapData 对象数量: ${fileMapList.size}") + + // 将 FileMapData 对象数组转换为 PhotoData 列表 + initialPhotos = fileMapList.mapNotNull { fileMap -> + val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl + if (thumbnailUrl.isNullOrEmpty()) { + LogUtils.i("PaySuccessViewModel", "FileMapData id=${fileMap.id} 的 thumbnailOssUrl 为空,跳过") + null + } else { + PhotoData( + id = fileMap.id, + url = thumbnailUrl.trim(), + aspectRatio = 1f, + fileMapData = fileMap // 保存完整的 FileMapData 对象,供后续使用 + ) + } + } + LogUtils.d("PaySuccessViewModel", "成功创建 PhotoData 列表,数量: ${initialPhotos.size}") + } catch (e: Exception) { + LogUtils.e("PaySuccessViewModel", "解析 JSON 格式失败: ${e.message}") + e.printStackTrace() + initialPhotos = emptyList() + } + } else { + // 兼容旧格式:id:url 或 url + initialPhotos = photoUrls.mapNotNull { urlStr -> + val trimmed = urlStr.trim() + if (trimmed.contains(":")) { + // id:url 格式 + val parts = trimmed.split(":", limit = 2) + if (parts.size == 2) { + val id = parts[0].toIntOrNull() + val url = parts[1] + if (id != null && url.isNotEmpty()) { + PhotoData( + id = id, + url = url, + aspectRatio = 1f + ) + } else null + } else null + } else { + // 只有 url,使用 hashCode 作为临时 id(不推荐,但兼容) + PhotoData( + id = trimmed.hashCode(), + url = trimmed, + aspectRatio = 1f + ) + } + } + } + + if (initialPhotos.isEmpty()) { + _photoList.value = emptyList() + _selectedPhotoIds.value = emptySet() + return@launch + } + + _photoList.value = initialPhotos + // 默认全选(使用 id) + _selectedPhotoIds.value = initialPhotos.map { it.id }.toSet() + + // 并发获取所有图片的真实尺寸并更新宽高比 + val updatedPhotos = initialPhotos.mapIndexed { index, photo -> + async(Dispatchers.IO) { + try { + val aspectRatio = getImageAspectRatio(photo.url) + // 确保 aspectRatio 是有效值 + val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) { + aspectRatio + } else { + 1f + } + photo.copy(aspectRatio = validAspectRatio) + } catch (e: Exception) { + LogUtils.e("PaySuccessViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}") + // 获取失败,保持默认值 + photo + } + } + }.awaitAll() + + _photoList.value = updatedPhotos + } + } + + /** + * 获取图片的宽高比 + */ + private suspend fun getImageAspectRatio(imageUrl: String): Float = withContext(Dispatchers.IO) { + try { + val imageLoader = ImageLoader(App.instance) + val request = ImageRequest.Builder(App.instance) + .data(imageUrl) + .size(Size.ORIGINAL) + .build() + + val drawable = imageLoader.execute(request).drawable + if (drawable != null) { + val width = drawable.intrinsicWidth + val height = drawable.intrinsicHeight + if (width > 0 && height > 0) { + return@withContext width.toFloat() / height.toFloat() + } + } + } catch (e: Exception) { + // 获取失败,使用默认值 + } + return@withContext 1f // 默认1:1 + } + + /** + * 切换单张图片选中状态 + */ + fun togglePhotoSelection(id: Int) { + val current = _selectedPhotoIds.value.toMutableSet() + if (current.contains(id)) { + current.remove(id) + } else { + current.add(id) + } + _selectedPhotoIds.value = current + } + + /** + * 切换全选状态 + */ + fun toggleSelectAll() { + val allIds = _photoList.value.map { it.id }.toSet() + val newSelected = if (_selectedPhotoIds.value.size == allIds.size) { + // 当前全选,取消全选 + emptySet() + } else { + // 全选 + allIds + } + _selectedPhotoIds.value = newSelected + } + + /** + * 点击打印 + */ + fun onPrintClick() { + val selectedIds = _selectedPhotoIds.value.toList() + if (selectedIds.isEmpty()) return + + // 获取选中的图片的完整 FileMapData 列表 + val selectedFileMapData = _photoList.value + .filter { selectedIds.contains(it.id) } + .mapNotNull { it.fileMapData } + + if (selectedFileMapData.isEmpty()) { + LogUtils.e("PaySuccessViewModel", "选中的图片没有 FileMapData,无法打印") + return + } + + // 将 FileMapData 数组序列化为 JSON 数组字符串 + val gson = Gson() + val jsonArray = gson.toJson(selectedFileMapData) + val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8") + + // 构建路由参数 + val orderNumberParam = _orderNumber.value.let { + if (it.isNotEmpty()) java.net.URLEncoder.encode(it, "UTF-8") else "" + } + val captureTypeParam = _captureType.value?.toString() ?: "" + + // 创建 NavOptions,清除回退栈(清除到支付成功页面,包括它自己) + val navOptions = NavOptions.Builder() + .setPopUpTo(AppRoutes.PAY_SUCCESS, inclusive = true) + .build() + + // 跳转到打印页面 + val route = "${AppRoutes.PRINTING}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam" + toPage(route, navOptions) + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt new file mode 100644 index 0000000..f357944 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt @@ -0,0 +1,534 @@ +package com.yzx.kiosk.ui.upload.viewmodel + +import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Size +import com.yzx.kiosk.App +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.network.model.request.GetPayUrlRequest +import com.yzx.kiosk.network.model.request.VerifyResultRequest +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.utils.ToastUtils +import com.yzx.kiosk.websocket.WebSocketService +import com.yzx.kiosk.websocket.UploadPhotoEvent +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import com.yzx.kiosk.datastore.AppStoreDataSource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import com.yzx.kiosk.utils.LogUtils +import javax.inject.Inject + +/** + * 文件映射数据(从 file_map 中获取) + * 对应 JSON 结构: + * { + * "id": 804, + * "original_oss_url": "...", + * "thumbnail_oss_url": "...", + * "thumbnail_lan_url": "...", + * "original_lan_url": "..." + * } + */ +data class FileMapData( + @SerializedName("id") + val id: Int, + + @SerializedName("original_oss_url") + val originalOssUrl: String?, + + @SerializedName("thumbnail_oss_url") + val thumbnailOssUrl: String?, + + @SerializedName("thumbnail_lan_url") + val thumbnailLanUrl: String?, + + @SerializedName("original_lan_url") + val originalLanUrl: String? +) + +data class PhotoData( + val id: Int, // 图片ID + val url: String, // 图片URL(thumbnail_oss_url,用于显示) + val aspectRatio: Float = 1f, // 宽高比,默认1:1 + val fileMapData: FileMapData? = null // 完整的文件映射数据 +) + +@HiltViewModel +class PhotoSelectViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val netWorkRepository: NetWorkRepository, + private val appStoreDataSource: AppStoreDataSource, + private val webSocketService: WebSocketService +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + // 图片列表 + private val _photoList = MutableStateFlow>(emptyList()) + val photoList: StateFlow> = _photoList.asStateFlow() + + // 已选中的图片ID + private val _selectedPhotoIds = MutableStateFlow>(emptySet()) + val selectedPhotoIds: StateFlow> = _selectedPhotoIds.asStateFlow() + + // 单价(从接口获取) + private val _pricePerPhoto = MutableStateFlow("--") + val pricePerPhoto: StateFlow = _pricePerPhoto.asStateFlow() + + // 总价(从接口获取) + private val _totalPrice = MutableStateFlow("--") + val totalPrice: StateFlow = _totalPrice.asStateFlow() + + // 支付二维码URL + private val _payQrCodeUrl = MutableStateFlow(null) + val payQrCodeUrl: StateFlow = _payQrCodeUrl.asStateFlow() + + /** 与首页一致的客服电话(本地配置,定时刷新) */ + private val _homepageServicePhone = MutableStateFlow(appStoreDataSource.getHomepageServicePhone()) + val homepageServicePhone: StateFlow = _homepageServicePhone.asStateFlow() + + init { + viewModelScope.launch { + while (true) { + delay(1000) + val latest = appStoreDataSource.getHomepageServicePhone() + if (latest != _homepageServicePhone.value) { + _homepageServicePhone.value = latest + } + } + } + // 监听 WebSocket 上传照片相关事件 + webSocketService.uploadPhotoEvents + .onEach { event -> + when (event) { + is UploadPhotoEvent.FileListReceived -> { + // 收到文件列表,增加到当前列表 + if (event.fileList.isNotEmpty()) { + LogUtils.d("PhotoSelectViewModel", "收到文件列表,增加到列表 - 文件数量: ${event.fileList.size}") + addFilesToList(event.fileList) + } + } + is UploadPhotoEvent.PaySuccess -> { + // 支付成功,跳转到支付成功页面 + LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}") + navigateToPaySuccess(event.orderNumber, event.captureType, event.fileMap) + } + else -> { + // 其他事件不处理 + } + } + } + .launchIn(viewModelScope) + } + + /** + * 初始化图片列表(从外部传入) + * 仅接收从扫码页面传入的数据,不使用模拟数据 + * urls 格式: + * - JSON 格式(新):FileMapData 数组的 JSON 字符串 + * - id:url 格式(兼容):id:url,id:url + * - url 格式(兼容):url,url + */ + fun initPhotoList(urls: List) { + if (urls.isEmpty()) { + // 如果没有传入数据,清空列表 + _photoList.value = emptyList() + _selectedPhotoIds.value = emptySet() + return + } + + viewModelScope.launch { + var initialPhotos: List + + // 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串) + if (urls.size == 1 && urls[0].startsWith("[")) { + // JSON 数组格式:将 file_map 列表序列化后的 JSON 数组字符串反序列化为 FileMapData 对象数组 + try { + val jsonString = java.net.URLDecoder.decode(urls[0], "UTF-8") + LogUtils.d("PhotoSelectViewModel", "接收到 JSON 数组字符串: $jsonString") + + // 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组 + val type = object : TypeToken>() {}.type + val fileMapList: List = Gson().fromJson(jsonString, type) ?: emptyList() + LogUtils.d("PhotoSelectViewModel", "反序列化成功,FileMapData 对象数量: ${fileMapList.size}") + + // 将 FileMapData 对象数组转换为 PhotoData 列表 + initialPhotos = fileMapList.mapNotNull { fileMap -> + val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl + if (thumbnailUrl.isNullOrEmpty()) { + LogUtils.i("PhotoSelectViewModel", "FileMapData id=${fileMap.id} 的 thumbnailOssUrl 为空,跳过") + null + } else { + PhotoData( + id = fileMap.id, + url = thumbnailUrl.trim(), + aspectRatio = 1f, + fileMapData = fileMap // 保存完整的 FileMapData 对象,供后续使用 + ) + } + } + LogUtils.d("PhotoSelectViewModel", "成功创建 PhotoData 列表,数量: ${initialPhotos.size}") + } catch (e: Exception) { + LogUtils.e("PhotoSelectViewModel", "解析 JSON 格式失败: ${e.message}") + e.printStackTrace() + initialPhotos = emptyList() + } + } else { + // 兼容旧格式:id:url 或 url + initialPhotos = urls.mapNotNull { urlStr -> + val trimmed = urlStr.trim() + if (trimmed.contains(":")) { + // id:url 格式 + val parts = trimmed.split(":", limit = 2) + if (parts.size == 2) { + val id = parts[0].toIntOrNull() + val url = parts[1] + if (id != null && url.isNotEmpty()) { + PhotoData( + id = id, + url = url, + aspectRatio = 1f + ) + } else null + } else null + } else { + // 只有 url,使用 hashCode 作为临时 id(不推荐,但兼容) + PhotoData( + id = trimmed.hashCode(), + url = trimmed, + aspectRatio = 1f + ) + } + } + } + + if (initialPhotos.isEmpty()) { + _photoList.value = emptyList() + _selectedPhotoIds.value = emptySet() + return@launch + } + + _photoList.value = initialPhotos + _selectedPhotoIds.value = emptySet() + + // 初始化时调用验证接口(空列表) + verifyResult(emptyList()) + + // 并发获取所有图片的真实尺寸并更新宽高比 + val updatedPhotos = initialPhotos.mapIndexed { index, photo -> + async(Dispatchers.IO) { + try { + val aspectRatio = getImageAspectRatio(photo.url) + // 确保 aspectRatio 是有效值 + val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) { + aspectRatio + } else { + 1f + } + photo.copy(aspectRatio = validAspectRatio) + } catch (e: Exception) { + LogUtils.e("PhotoSelectViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}") + // 获取失败,保持默认值 + photo + } + } + }.awaitAll() + + _photoList.value = updatedPhotos + } + } + + /** + * 将文件列表添加到当前列表(去重,基于 id) + */ + private fun addFilesToList(fileMapList: List) { + viewModelScope.launch { + val currentList = _photoList.value.toMutableList() + val existingIds = currentList.map { it.id }.toSet() + + // 将新文件转换为 PhotoData(去重) + val newPhotos = fileMapList.mapNotNull { fileMap -> + // 如果已存在,跳过 + if (existingIds.contains(fileMap.id)) { + LogUtils.d("PhotoSelectViewModel", "文件 id=${fileMap.id} 已存在,跳过") + null + } else { + val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl + if (thumbnailUrl.isNullOrEmpty()) { + LogUtils.i("PhotoSelectViewModel", "FileMapData id=${fileMap.id} 的 thumbnailUrl 为空,跳过") + null + } else { + PhotoData( + id = fileMap.id, + url = thumbnailUrl.trim(), + aspectRatio = 1f, // 临时值,稍后更新 + fileMapData = fileMap + ) + } + } + } + + if (newPhotos.isEmpty()) { + LogUtils.d("PhotoSelectViewModel", "没有新文件需要添加") + return@launch + } + + // 添加到当前列表 + val updatedList = currentList + newPhotos + _photoList.value = updatedList + + LogUtils.d("PhotoSelectViewModel", "已添加 ${newPhotos.size} 个新文件,当前总数: ${updatedList.size}") + + // 并发获取新图片的真实尺寸并更新宽高比 + val updatedNewPhotos = newPhotos.map { photo -> + async(Dispatchers.IO) { + try { + val aspectRatio = getImageAspectRatio(photo.url) + // 确保 aspectRatio 是有效值 + val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) { + aspectRatio + } else { + 1f + } + photo.copy(aspectRatio = validAspectRatio) + } catch (e: Exception) { + LogUtils.e("PhotoSelectViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}") + // 获取失败,保持默认值 + photo + } + } + }.awaitAll() + + // 合并当前列表和更新后的新图片 + val finalList = currentList + updatedNewPhotos + + _photoList.value = finalList + LogUtils.d("PhotoSelectViewModel", "文件列表更新完成,总数: ${finalList.size}") + } + } + + /** + * 获取图片的宽高比(优化版本:使用缩略图尺寸,添加超时) + */ + private suspend fun getImageAspectRatio(imageUrl: String): Float { + return withTimeoutOrNull(5000) { // 5秒超时 + try { + val imageLoader = ImageLoader(App.instance) + // 使用较小的尺寸来获取宽高比,提高加载速度 + val request = ImageRequest.Builder(App.instance) + .data(imageUrl) + .size(Size(200, 200)) // 使用缩略图尺寸,而不是原始尺寸 + .allowHardware(false) + .build() + + val result = imageLoader.execute(request) + val drawable = result.drawable + + if (drawable != null) { + val width = drawable.intrinsicWidth + val height = drawable.intrinsicHeight + + if (width > 0 && height > 0) { + val aspectRatio = width.toFloat() / height.toFloat() + return@withTimeoutOrNull aspectRatio + } + } + } catch (e: Exception) { + LogUtils.e("PhotoSelectViewModel", "获取图片 $imageUrl 尺寸异常: ${e.message}") + } + null + } ?: run { + // 超时或失败,返回默认值 + 1f + } + } + + /** + * 切换单张图片选中状态 + */ + fun togglePhotoSelection(id: Int) { + val current = _selectedPhotoIds.value.toMutableSet() + if (current.contains(id)) { + current.remove(id) + } else { + current.add(id) + } + _selectedPhotoIds.value = current + + // 每次选择变化时,调用验证接口 + verifyResult(current.toList()) + } + + /** + * 切换全选状态 + */ + fun toggleSelectAll() { + val allIds = _photoList.value.map { it.id }.toSet() + val newSelected = if (_selectedPhotoIds.value.size == allIds.size) { + // 当前全选,取消全选 + emptySet() + } else { + // 全选 + allIds + } + _selectedPhotoIds.value = newSelected + + // 每次选择变化时,调用验证接口 + verifyResult(newSelected.toList()) + } + + /** + * 验证结果接口 + * @param imageIds 选中的图片ID数组 + */ + private fun verifyResult(imageIds: List) { + viewModelScope.launch { + try { + val request = VerifyResultRequest( + type = 1, + imageId = imageIds + ) + + handleResultWithData( + flow = netWorkRepository.verifyResult(request).asResult(), + showToast = false, + onData = { response -> + // 更新单价(使用接口返回的 price_image) + val priceImage = response.priceImage + if (!priceImage.isNullOrEmpty()) { + _pricePerPhoto.value = priceImage + } else { + _pricePerPhoto.value = "--" + } + + // 更新总价(使用接口返回的 amount) + val amount = response.amount + if (!amount.isNullOrEmpty()) { + _totalPrice.value = amount + } else { + _totalPrice.value = "--" + } + + LogUtils.d("PhotoSelectViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount") + }, + onError = { msg, _ -> + LogUtils.e("PhotoSelectViewModel", "验证结果失败: $msg") + // 接口失败时,保持当前值或设置为默认值 + if (_selectedPhotoIds.value.isEmpty()) { + _pricePerPhoto.value = "--" + _totalPrice.value = "--" + } + } + ) + } catch (e: Exception) { + LogUtils.e("PhotoSelectViewModel", "验证结果异常: ${e.message}") + if (_selectedPhotoIds.value.isEmpty()) { + _pricePerPhoto.value = "--" + _totalPrice.value = "--" + } + } + } + } + + /** + * 获取支付URL + */ + fun getPayUrl(onSuccess: (String) -> Unit) { + viewModelScope.launch { + val selectedIds = _selectedPhotoIds.value.toList() + if (selectedIds.isEmpty()) { + ToastUtils.show("请选择要打印的照片") + return@launch + } + + try { + val request = GetPayUrlRequest( + type = 1, + imageId = selectedIds + ) + + handleResultWithData( + flow = netWorkRepository.getPayUrl(request).asResult(), + showToast = false, + onData = { response -> + val url = response.url + if (url.isNullOrEmpty()) { + ToastUtils.show("获取支付二维码失败") + } else { + _payQrCodeUrl.value = url + onSuccess(url) + } + }, + onError = { msg, _ -> + LogUtils.e("PhotoSelectViewModel", "获取支付URL失败: $msg") + ToastUtils.show("获取支付二维码失败") + } + ) + } catch (e: Exception) { + LogUtils.e("PhotoSelectViewModel", "获取支付URL异常: ${e.message}") + ToastUtils.show("获取支付二维码失败") + } + } + } + + /** + * 点击支付 + */ + fun onPayClick() { + val count = _selectedPhotoIds.value.size + if (count == 0) { + ToastUtils.show("请选择要打印的照片") + return + } + // 获取支付URL的逻辑在UI中处理 + } + + /** + * 跳转到支付成功页面 + * @param orderNumber 订单号 + * @param captureType 抓拍类型 + * @param imageIds 图片ID数组(从支付成功消息中获取) + */ + private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, fileMap: String) { + viewModelScope.launch { + + if (fileMap.isBlank()) { + LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap") + ToastUtils.show("支付成功,但未找到对应的图片数据") + return@launch + } + + + // 构建路由参数 + val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" + val captureTypeParam = captureType?.toString() ?: "" + + // 创建 NavOptions,清除选择页面 + val navOptions = androidx.navigation.NavOptions.Builder() + .setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.PHOTO_SELECT, inclusive = true) + .build() + + // 跳转到支付成功页面 + val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$fileMap&orderNumber=$orderNumberParam&captureType=$captureTypeParam" + toPage(route, navOptions) + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt new file mode 100644 index 0000000..a26e634 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt @@ -0,0 +1,549 @@ +package com.yzx.kiosk.ui.upload.viewmodel + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.lifecycle.viewModelScope +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.network.model.request.PrintCompleteRequest +import com.yzx.kiosk.network.model.request.PrintNotifyRequest +import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.Result +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.priter.PrintCallback +import com.yzx.kiosk.priter.PrintRecord +import com.yzx.kiosk.priter.PrintStatusManager +import com.yzx.kiosk.priter.PrinterService +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.yzx.kiosk.datastore.AppStoreDataSource +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import java.net.URL +import kotlin.coroutines.resume +import javax.inject.Inject + +@HiltViewModel +class PrintingViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val printerService: PrinterService, + private val appStoreDataSource: AppStoreDataSource, + private val printStatusManager: PrintStatusManager, + private val netWorkRepository: NetWorkRepository +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + companion object { + private const val TAG = "PrintingViewModel" + private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒) + private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒) + } + + /** + * 打印状态通知请求数据类 + */ + private data class PrintNotifyTask( + val captureType: Int, + val orderNumber: String, + val imageId: Int, + val printStatus: Int + ) + + // 打印状态通知请求队列,使用 Channel 确保所有请求都能被处理 + private val printNotifyChannel = Channel(Channel.UNLIMITED) + + // 是否正在打印 + private val _isPrinting = MutableStateFlow(true) + val isPrinting: StateFlow = _isPrinting.asStateFlow() + // 客服电话 + private val _hotline = MutableStateFlow("") + val hotline: StateFlow = _hotline.asStateFlow() + + // 预计等待时间 + private val _estimatedTime = MutableStateFlow(0) + val estimatedTime: StateFlow = _estimatedTime.asStateFlow() + + // 预计等待时间倒计时协程 + private var estimatedTimeCountdownJob: Job? = null + + // 打印完成后倒计时 + private val _countdown = MutableStateFlow(COUNTDOWN_AFTER_PRINT) + val countdown: StateFlow = _countdown.asStateFlow() + + // 打印记录列表 + private val _printRecords = MutableStateFlow>(emptyList()) + val printRecords: StateFlow> = _printRecords.asStateFlow() + + // 二维码 URL + private val _qrCodeUrl = MutableStateFlow("") + val qrCodeUrl: StateFlow = _qrCodeUrl.asStateFlow() + + init { + _hotline.value = appStoreDataSource.getHomepageServicePhone() + // 启动一个协程来处理打印状态通知队列,确保串行执行 + viewModelScope.launch(Dispatchers.IO) { + processPrintNotifyQueue() + } + } + + /** + * 处理打印状态通知队列,串行执行所有请求 + */ + private suspend fun processPrintNotifyQueue() { + LogUtils.d(TAG, ">>> 打印状态通知队列处理协程已启动 <<<") + try { + while (true) { + try { + LogUtils.d(TAG, ">>> 等待从队列中接收打印状态通知任务... <<<") + // Channel.receive() 在协程被取消时会抛出 CancellationException + val task = printNotifyChannel.receive() + LogUtils.d(TAG, ">>> 从队列中取出打印状态通知任务 - imageId: ${task.imageId}, printStatus: ${task.printStatus}, orderNumber: ${task.orderNumber}") + + // 执行实际的接口调用 + executePrintNotify( + captureType = task.captureType, + orderNumber = task.orderNumber, + imageId = task.imageId, + printStatus = task.printStatus + ) + LogUtils.d(TAG, ">>> 打印状态通知任务处理完成 - imageId: ${task.imageId} <<<") + } catch (e: CancellationException) { + LogUtils.d(TAG, "打印状态通知队列处理协程被取消") + break + } catch (e: Exception) { + LogUtils.e(TAG, "处理打印状态通知队列时发生异常: ${e.message}") + e.printStackTrace() + // 继续处理下一个任务,不中断循环 + } + } + } catch (e: CancellationException) { + LogUtils.d(TAG, "打印状态通知队列处理协程被取消(外层)") + } catch (e: Exception) { + LogUtils.e(TAG, "打印状态通知队列处理协程发生严重异常: ${e.message}") + e.printStackTrace() + } + LogUtils.d(TAG, ">>> 打印状态通知队列处理协程已结束 <<<") + } + + /** + * 开始打印 + * @param photoUrls 图片URL列表(JSON 格式或旧格式) + * @param orderNumber 订单号 + * @param captureType 抓拍类型 + */ + fun startPrinting(photoUrls: List, orderNumber: String = "", captureType: Int? = null) { + if (photoUrls.isEmpty()) { + ToastUtils.show("没有要打印的照片") + return + } + + if (orderNumber.isEmpty() || captureType == null) { + LogUtils.e(TAG, "订单号或抓拍类型为空,无法打印") + ToastUtils.show("订单信息不完整,无法打印") + return + } + + viewModelScope.launch { + _isPrinting.value = true + _printRecords.value = emptyList() + + // 获取二维码 URL + loadQrCodeUrl(orderNumber) + + // 解析图片列表(支持 JSON 格式的 FileMapData 数组) + val printItems = parsePhotoList(photoUrls) + if (printItems.isEmpty()) { + LogUtils.e(TAG, "解析图片列表失败") + ToastUtils.show("图片列表解析失败") + onPrintComplete(orderNumber, captureType) + return@launch + } + + _estimatedTime.value = printItems.size * PRINT_TIME_PER_PHOTO + + // 启动预计等待时间倒计时 + startEstimatedTimeCountdown() + + // 初始化打印机 + val initResult = printerService.initDnpPrint() + val isPrinterAvailable = initResult && !printStatusManager.isOffline() + + if (!initResult) { + LogUtils.e(TAG, "打印机初始化失败,将为所有图片上传打印失败状态") + ToastUtils.show("打印机初始化失败") + } else if (printStatusManager.isOffline()) { + LogUtils.e(TAG, "打印机离线,将为所有图片上传打印失败状态") + ToastUtils.show("打印机离线") + } + + val records = mutableListOf() + + // 循环处理每张图片(无论打印机是否在线,都要上传打印状态) + for ((index, item) in printItems.withIndex()) { + // 更新预计时间(倒计时协程会自动减少,这里只更新剩余时间) + val remainingTime = (printItems.size - index) * PRINT_TIME_PER_PHOTO + if (remainingTime > _estimatedTime.value) { + _estimatedTime.value = remainingTime + } + + LogUtils.d(TAG, "开始处理第 ${index + 1}/${printItems.size} 张图片 - imageId: ${item.id}, url: ${item.originalOssUrl}") + + // 1. 检查打印机在线状态(如果初始化失败或离线,直接标记为失败) + if (!isPrinterAvailable || printStatusManager.isOffline()) { + LogUtils.e(TAG, "打印机不可用,标记为打印失败 - imageId: ${item.id}") + val printStatus = 2 // 失败 + // 调用 print-notify 接口上传打印状态 + notifyPrintStatus(captureType, orderNumber, item.id, printStatus) + val record = PrintRecord( + fileName = item.originalOssUrl.substringAfterLast("/"), + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = if (!initResult) "打印机初始化失败" else "打印机离线" + ) + records.add(record) + appStoreDataSource.addPrintRecord(record) + continue // 继续处理下一张图片 + } + + // 2. 下载图片(使用 original_oss_url) + + val bitmap = downloadImage(item.originalOssUrl) + if (bitmap == null) { + LogUtils.e(TAG, "图片下载失败 - imageId: ${item.id}, url: ${item.originalOssUrl}") + val printStatus = 2 // 失败 + // 调用 print-notify 接口 + notifyPrintStatus(captureType, orderNumber, item.id, printStatus) + val record = PrintRecord( + fileName = item.originalOssUrl.substringAfterLast("/"), + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "图片下载失败" + ) + records.add(record) + appStoreDataSource.addPrintRecord(record) + continue // 继续下一张 + } + + // 3. 打印图片,等待回调结果 + val printResult = printImageWithCallback(bitmap) + val printStatus = if (printResult.isSuccess) 1 else 2 // 1=成功, 2=失败 + + // 调用 print-notify 接口 + notifyPrintStatus(captureType, orderNumber, item.id, printStatus) + + val record = PrintRecord( + fileName = item.originalOssUrl.substringAfterLast("/"), + printEndTime = System.currentTimeMillis(), + isSuccess = printResult.isSuccess, + message = printResult.message + ) + records.add(record) + appStoreDataSource.addPrintRecord(record) + + // 回收Bitmap + bitmap.recycle() + + LogUtils.d(TAG, "打印${if (printResult.isSuccess) "成功" else "失败"} - imageId: ${item.id}, message: ${printResult.message}") + } + + _printRecords.value = records + + // 打印完所有图片,调用 print-complete 接口 + onPrintComplete(orderNumber, captureType) + } + } + + /** + * 解析图片列表 + * 支持 JSON 格式的 FileMapData 数组或旧格式的 URL 列表 + */ + private suspend fun parsePhotoList(photoUrls: List): List = withContext(Dispatchers.IO) { + if (photoUrls.isEmpty()) { + return@withContext emptyList() + } + + // 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串) + if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) { + try { + val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8") + LogUtils.d(TAG, "解析 JSON 数组字符串: $jsonString") + + // 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组 + val type = object : TypeToken>() {}.type + val fileMapList: List = Gson().fromJson(jsonString, type) ?: emptyList() + + // 转换为 PrintItem 列表 + return@withContext fileMapList.mapNotNull { fileMap -> + val originalUrl = if (appStoreDataSource.getUseLan()) fileMap.originalLanUrl else fileMap.originalOssUrl ?:fileMap.thumbnailOssUrl + + if (originalUrl.isNullOrEmpty()) { + LogUtils.w(TAG, "FileMapData id=${fileMap.id} 的 originalOssUrl 为空,跳过") + null + } else { + PrintItem( + id = fileMap.id, + originalOssUrl = originalUrl.trim() + ) + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析 JSON 格式失败: ${e.message}") + e.printStackTrace() + return@withContext emptyList() + } + } else { + // 兼容旧格式:URL 列表 + return@withContext photoUrls.mapIndexed { index, url -> + PrintItem( + id = index + 1, // 使用索引作为临时 id + originalOssUrl = url.trim() + ) + } + } + } + + /** + * 打印图片并等待回调结果 + */ + private suspend fun printImageWithCallback(bitmap: Bitmap): PrintResult = suspendCancellableCoroutine { continuation -> + // 在协程作用域中调用挂起函数 + kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { + printerService.startPrint(bitmap, object : PrintCallback { + override fun onPrintResult(isSuccess: Boolean, msg: String) { + continuation.resume(PrintResult(isSuccess, msg)) + } + }) + } + } + + /** + * 通知打印状态(异步,将请求放入队列) + * 使用 Channel 队列确保高并发调用时所有请求都能被处理,串行执行避免并发问题 + */ + private suspend fun notifyPrintStatus( + captureType: Int, + orderNumber: String, + imageId: Int, + printStatus: Int + ) { + try { + val task = PrintNotifyTask( + captureType = captureType, + orderNumber = orderNumber, + imageId = imageId, + printStatus = printStatus + ) + + // 将请求放入队列,如果队列已满会挂起等待 + printNotifyChannel.send(task) + LogUtils.d(TAG, ">>> 打印状态通知请求已加入队列 <<< - imageId: $imageId, printStatus: $printStatus") + } catch (e: Exception) { + LogUtils.e(TAG, ">>> 将打印状态通知请求加入队列失败 <<< - imageId: $imageId, error: ${e.message}") + e.printStackTrace() + } + } + + /** + * 执行实际的打印状态通知接口调用 + */ + private suspend fun executePrintNotify( + captureType: Int, + orderNumber: String, + imageId: Int, + printStatus: Int + ) { + try { + LogUtils.d(TAG, ">>> 开始执行 print-notify 接口调用 <<<") + LogUtils.d(TAG, "参数 - captureType: $captureType, orderNumber: $orderNumber, imageId: $imageId, printStatus: $printStatus") + + // 如果打印成功,更新打印纸张数(减1) + var remainingPaperNum: Int? = null + if (printStatus == 1) { + val currentPaperNum = appStoreDataSource.getRemainingPaperNum() + val newPaperNum = (currentPaperNum - 1).coerceAtLeast(0) + appStoreDataSource.saveRemainingPaperNum(newPaperNum) + // 同步更新全局状态,以便首页实时更新 + appState.updateRemainingPaperNum(newPaperNum) + remainingPaperNum = newPaperNum + LogUtils.d(TAG, "打印成功,更新纸张数: $currentPaperNum -> $newPaperNum") + } + + val request = PrintNotifyRequest( + captureType = captureType, + orderNumber = orderNumber, + imageId = imageId, + printStatus = printStatus, + remainingPaperNum = remainingPaperNum + ) + + handleResultWithData( + flow = netWorkRepository.printNotify(request).asResult(), + showToast = false, + onData = { + LogUtils.d(TAG, ">>> print-notify 接口调用成功 <<< - imageId: $imageId, printStatus: $printStatus") + }, + onError = { msg, _ -> + LogUtils.e(TAG, ">>> print-notify 接口调用失败 <<< - imageId: $imageId, error: $msg") + } + ) + } catch (e: Exception) { + LogUtils.e(TAG, ">>> print-notify 接口调用异常 <<< - imageId: $imageId, error: ${e.message}") + e.printStackTrace() + } + } + + /** + * 打印项数据类 + */ + private data class PrintItem( + val id: Int, + val originalOssUrl: String + ) + + /** + * 打印结果 + */ + private data class PrintResult( + val isSuccess: Boolean, + val message: String + ) + + /** + * 启动预计等待时间倒计时 + */ + private fun startEstimatedTimeCountdown() { + // 取消之前的倒计时(如果存在) + estimatedTimeCountdownJob?.cancel() + + // 启动新的倒计时 + estimatedTimeCountdownJob = viewModelScope.launch { + while (_isPrinting.value) { + delay(1000) // 每秒更新一次 + if (_estimatedTime.value > 0) { + _estimatedTime.value = _estimatedTime.value - 1 + } + // 如果倒计时到0,继续运行但不再减少(保持为0) + } + } + } + + /** + * 打印完成 + */ + private suspend fun onPrintComplete(orderNumber: String, captureType: Int) { + // 停止预计等待时间倒计时 + estimatedTimeCountdownJob?.cancel() + estimatedTimeCountdownJob = null + + // 调用 print-complete 接口 + notifyPrintComplete(captureType, orderNumber) + + _isPrinting.value = false + _countdown.value = COUNTDOWN_AFTER_PRINT + + // 开始倒计时 + viewModelScope.launch { + while (_countdown.value > 0) { + delay(1000) + _countdown.value = _countdown.value - 1 + } + // 倒计时结束,返回首页并清理回退栈 + navigator.closeAllExcept(AppRoutes.HOME) + } + } + + /** + * 通知打印完成 + */ + private suspend fun notifyPrintComplete(captureType: Int, orderNumber: String) { + try { + LogUtils.d(TAG, "调用 print-complete 接口 - captureType: $captureType, orderNumber: $orderNumber") + + val request = PrintCompleteRequest( + captureType = captureType, + orderNumber = orderNumber + ) + + handleResultWithData( + flow = netWorkRepository.printComplete(request).asResult(), + showToast = false, + onData = { + LogUtils.d(TAG, "print-complete 接口调用成功") + }, + onError = { msg, _ -> + LogUtils.e(TAG, "print-complete 接口调用失败: $msg") + } + ) + } catch (e: Exception) { + LogUtils.e(TAG, "print-complete 接口调用异常: ${e.message}") + e.printStackTrace() + } + } + + /** + * 下载网络图片 + */ + private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) { + try { + val connection = URL(url).openConnection() + connection.connectTimeout = 10000 + connection.readTimeout = 10000 + connection.getInputStream().use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } + } catch (e: Exception) { + LogUtils.e(TAG, "下载图片失败: $url, ${e.message}") + null + } + } + + /** + * 获取二维码 URL + */ + private fun loadQrCodeUrl(orderNumber: String) { + viewModelScope.launch { + try { + val request = SaveAlbumUrlRequest(orderNumber = orderNumber) + handleResultWithData( + flow = netWorkRepository.saveAlbumUrl(request).asResult(), + showToast = false, + onData = { response -> + val url = response?.url + if (!url.isNullOrEmpty()) { + _qrCodeUrl.value = url + LogUtils.d(TAG, "获取二维码 URL 成功: $url") + } else { + LogUtils.e(TAG, "获取二维码 URL 失败: url 为空") + } + }, + onError = { msg, _ -> + LogUtils.e(TAG, "获取二维码 URL 失败: $msg") + } + ) + } catch (e: Exception) { + LogUtils.e(TAG, "获取二维码 URL 异常: ${e.message}") + e.printStackTrace() + } + } + } + +} + diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt new file mode 100644 index 0000000..dbb9207 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt @@ -0,0 +1,273 @@ +package com.yzx.kiosk.ui.upload.viewmodel + +import androidx.lifecycle.viewModelScope +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.audio.LocalAudioPlayService +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import androidx.navigation.NavOptions +import com.yzx.kiosk.network.model.response.GetCodeUrlResponse +import com.yzx.kiosk.network.model.response.GetPrintInfoResponse +import com.yzx.kiosk.websocket.WebSocketService +import com.yzx.kiosk.websocket.UploadPhotoEvent +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class UploadPhotoViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + private val netWorkRepository: NetWorkRepository, + private val webSocketService: WebSocketService, + private val localAudioPlayService: LocalAudioPlayService +) : BaseViewModel( + navigator = navigator, + appState = appState +) { + // 二维码内容 + private val _qrCodeContent = MutableStateFlow("") + val qrCodeContent: StateFlow = _qrCodeContent.asStateFlow() + + // 是否正在加载 + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + // 倒计时 + private val _countdown = MutableStateFlow(180) + val countdown: StateFlow = _countdown.asStateFlow() + + // 照片规格 + private val _photoSpec = MutableStateFlow("--") + val photoSpec: StateFlow = _photoSpec.asStateFlow() + + // 打印价格 + private val _printPrice = MutableStateFlow("--") + val printPrice: StateFlow = _printPrice.asStateFlow() + + // 预计耗时 + private val _estimatedTime = MutableStateFlow("--") + val estimatedTime: StateFlow = _estimatedTime.asStateFlow() + + // 打印技术 + private val _printTech = MutableStateFlow("--") + val printTech: StateFlow = _printTech.asStateFlow() + + // 客服手机号 + private val _servicePhone = MutableStateFlow("--") + val servicePhone: StateFlow = _servicePhone.asStateFlow() + + // 是否显示扫码成功蒙层 + private val _showScanSuccess = MutableStateFlow(false) + val showScanSuccess: StateFlow = _showScanSuccess.asStateFlow() + + // 是否显示刷新确认对话框 + private val _showRefreshDialog = MutableStateFlow(false) + val showRefreshDialog: StateFlow = _showRefreshDialog.asStateFlow() + + // 倒计时Job + private var countdownJob: Job? = null + + init { + // 监听 WebSocket 上传照片相关事件 + webSocketService.uploadPhotoEvents + .onEach { event -> + when (event) { + is UploadPhotoEvent.ScanSuccess -> { + // 显示扫码成功蒙层 + _showScanSuccess.value = true + LogUtils.d("UploadPhotoViewModel", "收到扫码成功事件 - uuid: ${event.uuid}, openId: ${event.openId}") + // 播放扫码成功语音 + localAudioPlayService.playByRoute("upload_scan_success", forceRestart = true) + } + is UploadPhotoEvent.FileListReceived -> { + // 跳转到照片选择页面,传递文件列表(包含完整数据) + if (event.fileList.isNotEmpty()) { + LogUtils.d("UploadPhotoViewModel", "收到文件列表,跳转到选择页面 - 文件数量: ${event.fileList.size}") + // 播放未支付结果页语音 + localAudioPlayService.playByRoute("upload_unpaid_result", forceRestart = true) + // 将 FileMapData 列表序列化为 JSON 数组字符串 + val gson = com.google.gson.Gson() + val jsonArray = gson.toJson(event.fileList) + LogUtils.d("UploadPhotoViewModel", "序列化后的 JSON 数组: $jsonArray") + // URL 编码后传递 + val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8") + navigateToPhotoSelect(encodedJson) + } + } + is UploadPhotoEvent.PaySuccess -> { + // 支付成功事件由 PhotoSelectViewModel 处理,这里不需要处理 + LogUtils.d("UploadPhotoViewModel", "收到支付成功事件,但当前页面不需要处理") + } + } + } + .launchIn(viewModelScope) + } + + /** + * 加载二维码URL和打印服务信息 + */ + fun loadQrCodeUrl() { + viewModelScope.launch { + _isLoading.value = true + try { + // 并发加载二维码URL和打印服务信息 + val qrCodeJob = async { + netWorkRepository.getCodeUrl().first() + } + val printInfoJob = async { + netWorkRepository.getPrintInfo().first() + } + showLoading() + // 处理二维码URL + handleResultWithData( + flow = flow { emit(qrCodeJob.await()) }.asResult(), + showToast = false, + onData = { response -> + val url = response.url + if (url.isNullOrEmpty()) { + // URL为空,提示并返回首页 + ToastUtils.show("没有有效数据") + closeAllExcept(AppRoutes.HOME) + } else { + // 使用URL生成二维码 + _qrCodeContent.value = url + // 开始倒计时 + startCountdown() + } + }, + onError = { msg, _ -> + LogUtils.e("UploadPhotoViewModel", "获取二维码URL失败: $msg") + ToastUtils.show("获取二维码失败") + // 获取失败,返回首页 + closeAllExcept(AppRoutes.HOME) + }, + onEnd = { + hideLoading() + } + ) + + // 处理打印服务信息 + handleResultWithData( + flow = flow { emit(printInfoJob.await()) }.asResult(), + showToast = false, + onData = { response -> + // 更新照片规格 + _photoSpec.value = response.printerPhotoSize ?: "--" + // 更新打印价格 + _printPrice.value = response.pricePhotoPrint ?: "--" + // 更新预计耗时 + _estimatedTime.value = response.estimatedTime ?: "--" + // 更新打印技术 + _printTech.value = response.printerType ?: "--" + // 更新客服手机号 + _servicePhone.value = response.customerServicePhone ?: "--" + }, + onError = { msg, _ -> + LogUtils.e("UploadPhotoViewModel", "获取打印服务信息失败: $msg") + // 获取失败,保持默认值 "--" + } + ) + + _isLoading.value = false + } catch (e: Exception) { + LogUtils.e("UploadPhotoViewModel", "加载数据异常: ${e.message}") + ToastUtils.show("获取数据失败") + _isLoading.value = false + closeAllExcept(AppRoutes.HOME) + } + } + } + + /** + * 开始倒计时 + */ + fun startCountdown() { + // 取消之前的倒计时 + countdownJob?.cancel() + countdownJob = viewModelScope.launch { + while (_countdown.value > 0) { + delay(1000) + _countdown.value = _countdown.value - 1 + } + // 倒计时结束,自动返回 + navigateBack() + } + } + + /** + * 显示刷新确认对话框 + */ + fun showRefreshDialog() { + _showRefreshDialog.value = true + } + + /** + * 隐藏刷新确认对话框 + */ + fun hideRefreshDialog() { + _showRefreshDialog.value = false + } + + /** + * 刷新二维码 + */ + fun refreshQrCode() { + // 关闭对话框 + _showRefreshDialog.value = false + // 重置扫码成功状态 + _showScanSuccess.value = false + // 取消之前的倒计时 + countdownJob?.cancel() + countdownJob = null + // 重置倒计时 + _countdown.value = 180 + // 重新加载二维码 + loadQrCodeUrl() + } + + /** + * 点击二维码,跳转到照片选择页面,并清除二维码页面的回退栈 + */ + fun onQrCodeClick() { + if (_qrCodeContent.value.isNotEmpty()) { + // 创建 NavOptions,清除上传照片页面 + val navOptions = NavOptions.Builder() + .setPopUpTo(AppRoutes.UPLOAD_PHOTO, inclusive = true) + .build() + + toPage(AppRoutes.PHOTO_SELECT, navOptions) + } + } + + /** + * 跳转到照片选择页面,传递文件列表 + */ + private fun navigateToPhotoSelect(encodedUrls: String) { + viewModelScope.launch { + // 创建 NavOptions,清除上传照片页面 + val navOptions = NavOptions.Builder() + .setPopUpTo(AppRoutes.UPLOAD_PHOTO, inclusive = true) + .build() + + // 跳转到照片选择页面 + toPage("${AppRoutes.PHOTO_SELECT}?urls=$encodedUrls", navOptions) + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/utils/AliyunOSSUtil.kt b/app/src/main/java/com/yzx/kiosk/utils/AliyunOSSUtil.kt new file mode 100644 index 0000000..744de24 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/AliyunOSSUtil.kt @@ -0,0 +1,348 @@ +package com.yzx.kiosk.utils + +import android.content.Context +import androidx.core.net.toUri +import com.alibaba.sdk.android.oss.ClientConfiguration +import com.alibaba.sdk.android.oss.ClientException +import com.alibaba.sdk.android.oss.OSSClient +import com.alibaba.sdk.android.oss.ServiceException +import com.alibaba.sdk.android.oss.callback.OSSCompletedCallback +import com.alibaba.sdk.android.oss.common.auth.OSSCredentialProvider +import com.alibaba.sdk.android.oss.common.auth.OSSStsTokenCredentialProvider +import com.alibaba.sdk.android.oss.internal.OSSAsyncTask +import com.alibaba.sdk.android.oss.model.ResumableUploadRequest +import com.alibaba.sdk.android.oss.model.ResumableUploadResult +import com.hjq.toast.Toaster +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.model.entity.UploadTaskEntity +import com.yzx.kiosk.network.model.response.AliyunOSSResponse +import java.io.File +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + + +private data class PreparedPath( + val localPath: String, // 传给 ResumableUploadRequest 的真实文件路径 + val fileName: String, // 用于 objectKey / 校验扩展名 + val fromContentUri: Boolean, // 是否是由 content:// 暂存的 + val stageFile: File? // 暂存文件(非 content:// 时为 null) +) + +class AliyunOSSUtil @Inject constructor( + private val appStore: AppStoreDataSource, +) { + private var ossClient: OSSClient? = null + private var ossResponse: AliyunOSSResponse? = null + private val maxFileSize = 2048L * 1024 * 1024 // 2048MB + private var currentUploadRequest: ResumableUploadRequest? = null + + //新增20250828 + private var currentUploadTask: OSSAsyncTask? = null + + private var cancelledByUser = false + + // 初始化OSS客户端 + fun initOSSClient(context: Context, ossConfig: AliyunOSSResponse) { + val credentialProvider: OSSCredentialProvider = OSSStsTokenCredentialProvider( + ossConfig.credentials.access_key_id, + ossConfig.credentials.access_key_secret, + ossConfig.credentials.security_token + ) + + + val conf = ClientConfiguration() + + conf.connectionTimeout = 15 * 1000 // 连接超时,默认15秒 + conf.socketTimeout = 15 * 1000 // socket超时,默认15秒 + conf.maxConcurrentRequest = 5 // 最大并发请求数,默认5个 + conf.maxErrorRetry = 2 // 失败后最大重试次数,默认2次 + + + ossClient = OSSClient(context, ossConfig.endpoint, credentialProvider, conf) + // 设置Region(如果需要) + ossClient?.setRegion(ossConfig.region) + + this.ossResponse = AliyunOSSResponse( + endpoint = ossConfig.endpoint, + bucket = ossConfig.bucket, + credentials = ossConfig.credentials, + baseUrl = ossConfig.baseUrl, + region = ossConfig.region, + expire_seconds = ossConfig.expire_seconds, + + ) + } + + + // 检查文件大小是否超过限制 + fun isFileSizeExceedLimit(fileSize: Long): Boolean { + return fileSize > maxFileSize + } + + // 修改文件类型验证常量 + companion object { + private val ALLOWED_EXTENSIONS = setOf("mp4", "avi", "png", "jpg", "jpeg", "mp3", "wav") + } + + + fun uploadFile( + task: UploadTaskEntity, + onProgress: (Int, Long) -> Unit, + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + context: Context, + ) { + try { + if (ossClient == null) { + onFailure("OSS客户端未初始化") + return + } + + // ① 兼容 content:// → 准备真实本地路径 + val prepared = prepareLocalPathForOss(context, task) + val localPath = prepared.localPath + val realFile = File(localPath) + + // ② 大小/格式校验(扩展名从文件名推导,拿不到就用 MIME→扩展名映射) + val size = if (task.fileSize > 0) task.fileSize else realFile.length() + if (isFileSizeExceedLimit(size)) { + onFailure("文件大小不能超过2048MB") + return + } + // 扩展名校验 + var ext = FileUtils.getFileExtension(prepared.fileName).lowercase() + if (ext.isBlank()) { + // 简单的 MIME→扩展名兜底(可按需扩展) + val mime = context.contentResolver.getType(task.filePath.toUri()) ?: "" + ext = when { + mime.startsWith("video/") && mime.contains("mp4") -> "mp4" + mime.startsWith("video/") && mime.contains("avi") -> "avi" + mime.startsWith("image/") && mime.contains("jpeg") -> "jpg" + mime.startsWith("image/") && mime.contains("png") -> "png" + mime.startsWith("audio/") && mime.contains("mpeg") -> "mp3" + mime.startsWith("audio/") && mime.contains("mp3") -> "mp3" + mime.startsWith("audio/") && mime.contains("wav") -> "wav" + mime.startsWith("audio/") && mime.contains("wave") -> "wav" + else -> "" + } + } + if (ext !in ALLOWED_EXTENSIONS) { + Toaster.show("仅支持.mp4,.avi,.png,.jpg,.jpeg,.mp3,.wav格式") + return + } + + // ③ 生成 objectKey + val currentDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + val objectKey = + when (task.moduleType) { + "album" -> { + "photog_album${currentDate}/${appStore.getCurrentScenicId()}/${appStore.getUserId()}/${task.id}_${prepared.fileName}" + } + "material" -> { + "material/${appStore.getCurrentScenicId()}/${task.folderId}/${task.id}_${prepared.fileName}" + } + "alive_album" -> { + //alive_album - live_albums/20250101/119/6a18e9c039d4bcdb618eadc73f176e30.jpg + "live_albums/${currentDate}/${appStore.getCurrentScenicId()}/${task.id}_${prepared.fileName}" + } + "real_name_auth" -> { + "real_name_auth/${currentDate}/${appStore.getUserId()}/${task.id}_${prepared.fileName}" + } + "bank_card" -> { + "bank_card/${currentDate}/${appStore.getUserId()}/${task.id}_${prepared.fileName}" + } + "punch_point" -> { + "punch_point/${currentDate}/${appStore.getUserId()}/${task.id}_${prepared.fileName}" + } + "scenic_apply" -> { + "scenic_apply/${currentDate}/${appStore.getUserId()}/${task.id}_${prepared.fileName}" + } + else -> { + //live_covers - live_covers/202501/6a18e9c039d4bcdb618eadc73f176e30.jpg + "facebox/${currentDate}/${task.id}_${prepared.fileName}" + } + } + + val recordDirectory = File(context.cacheDir, "oss_record/${task.id}") + .apply { mkdirs() } + .absolutePath + + // ⑤ 创建断点续传请求(一定要传“真实本地路径”) + currentUploadRequest = ResumableUploadRequest( + task.bucket ?: (ossResponse?.bucket ?: run { + onFailure("未获取到有效的OSS存储桶信息"); return + }), + objectKey, + localPath, // ← 关键:不再使用 task.filePath(可能是 content://) + recordDirectory + ).apply { + setDeleteUploadOnCancelling(true) + setProgressCallback { _, currentSize, totalSize -> + val progress = + if (totalSize > 0) ((currentSize * 100 / totalSize).toInt()) else 0 + onProgress(progress, currentSize) + } + } + + // ⑥ 发起上传 + currentUploadTask = ossClient?.asyncResumableUpload( + currentUploadRequest, + object : OSSCompletedCallback { + override fun onSuccess( + req: ResumableUploadRequest, + res: ResumableUploadResult + ) { + val fileUrl = ossResponse?.baseUrl + objectKey + onSuccess(fileUrl) + // 上传成功:可以清理暂存文件节省空间 + if (prepared.fromContentUri) { + runCatching { prepared.stageFile?.delete() } + } + } + + override fun onFailure( + req: ResumableUploadRequest, + ce: ClientException?, + se: ServiceException? + ) { + val errorMsg = when { + cancelledByUser -> { + cancelledByUser = false + "__CANCELLED__" + } + + ce != null -> "客户端错误: ${ce.message}" + se != null -> "服务端错误: ${se.message}" + else -> "未知错误" + } + onFailure(errorMsg) + } + } + ) + + LogUtils.d("uploadFile: 上传任务已启动,path=$localPath, task=$currentUploadTask") + } catch (e: Exception) { + e.printStackTrace() + onFailure(e.message ?: "上传过程中发生错误") + } + } + + // 取消当前上传任务 + fun cancelUpload() { + cancelledByUser = true + // 1. 取消当前上传任务(终止SDK内部线程) + currentUploadTask?.cancel() // 调用cancel()会停止上传线程,并保存断点到checkpointFile + // 2. 清空任务引用(避免内存泄漏) + currentUploadTask = null + currentUploadRequest = null // 之后再清空引用 + + } + + + /** + * 清理过期的断点记录文件 + * @param context 上下文 + * @param expireDays 过期天数,默认7天 + */ + fun cleanExpiredCheckpointFiles(context: Context, expireDays: Int = 7) { + val recordDir = File(context.cacheDir, "oss_record") + if (!recordDir.exists()) { + return + } + + val expireTime = System.currentTimeMillis() - expireDays * 24 * 3600 * 1000L + + recordDir.listFiles()?.forEach { file -> + if (file.isFile && file.lastModified() < expireTime) { + file.delete() + LogUtils.d("Deleted expired checkpoint file: ${file.name}") + } + } + } + + /** + * 清理指定任务的断点文件 + * @param context 上下文 + * @param taskId 任务ID + */ + fun cleanCheckpointFileForTask(context: Context, taskId: String) { + val dir = File(context.cacheDir, "oss_record/$taskId") + if (dir.exists()) { + dir.deleteRecursively() + LogUtils.d("Deleted checkpoint dir for task $taskId: ${dir.absolutePath}") + } + } + + + private fun prepareLocalPathForOss(context: Context, task: UploadTaskEntity): PreparedPath { + val uri = task.filePath.toUri() + + // file:// 或 纯路径:直接用 + if (uri.scheme.isNullOrEmpty() || uri.scheme == "file") { + val f = if (!uri.scheme.isNullOrEmpty()) File(uri.path ?: task.filePath) + else File(task.filePath) + require(f.exists()) { "文件不存在: ${task.filePath}" } + return PreparedPath( + localPath = f.absolutePath, + fileName = task.fileName.ifBlank { f.name }, + fromContentUri = false, + stageFile = null + ) + } + + // content:// → 拷贝到本地缓存(如已存在且大小匹配则复用) + if (uri.scheme == "content") { + val resolver = context.contentResolver + // 1) 决定显示名(用于临时文件名和后续 objectKey) + var displayName = task.fileName + if (displayName.isBlank()) { + resolver.query( + uri, + arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), + null, null, null + )?.use { c -> + if (c.moveToFirst()) { + val idx = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && !c.isNull(idx)) { + displayName = c.getString(idx) + } + } + } + if (displayName.isBlank()) displayName = "unknown_${System.currentTimeMillis()}" + } + + // 2) 目标暂存文件(与 task.id 绑定,便于断点续传/复用) + val stageDir = File(context.cacheDir, "oss_stage").apply { mkdirs() } + val stageFile = File(stageDir, "${task.id}_$displayName") + + // 3) 如果不存在或大小不一致 → 流式拷贝(避免占内存) + if (!stageFile.exists() || (task.fileSize > 0 && stageFile.length() != task.fileSize)) { + resolver.openInputStream(uri)?.use { input -> + stageFile.outputStream().use { output -> + val buf = ByteArray(1024 * 1024) // 1MB buffer + var read: Int + while (true) { + read = input.read(buf) + if (read <= 0) break + output.write(buf, 0, read) + } + output.flush() + } + } ?: error("无法打开 Uri 输入流:$uri") + } + + require(stageFile.exists()) { "暂存失败:$stageFile" } + + return PreparedPath( + localPath = stageFile.absolutePath, + fileName = displayName, + fromContentUri = true, + stageFile = stageFile + ) + } + + error("不支持的 URI: ${task.filePath}") + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/utils/DataProvider.kt b/app/src/main/java/com/yzx/kiosk/utils/DataProvider.kt new file mode 100644 index 0000000..112cdc6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/DataProvider.kt @@ -0,0 +1,14 @@ +package com.yzx.kiosk.utils + +import java.time.LocalDate + +object DataProvider { + + fun getScheduleDays(): List { + return listOf( + LocalDate.now(), + LocalDate.now().plusDays(1), + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/DateUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/DateUtils.kt new file mode 100644 index 0000000..ad0126a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/DateUtils.kt @@ -0,0 +1,232 @@ +package com.yzx.kiosk.utils + +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.* + +import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit +import java.util.Locale + +object DateUtils { + + /** + * yyyy-MM格式字符串转LocalDate + */ + fun yearMonthStr2LocalDate(dateStr: String): LocalDate { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM")) + } + + fun localDate2YearMonthStr(date: LocalDate): String { + return date.format(DateTimeFormatter.ofPattern("yyyy-MM")) + } + + /** + * 2025-06-13 转LocalDate + */ + fun str2LocalDate(dateStr: String): LocalDate { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd")) + } + + fun localDate2Str(date: LocalDate): String { + return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + } + + /** + * LocalDate 转 2025 年 6 月 + */ + fun monthLocalDate2Str(date: LocalDate): String { + return date.format(DateTimeFormatter.ofPattern("yyyy 年 MM 月")) + } + + /** + * 时分秒 00:00:00 转 LocalTime + */ + fun str2LocalTime(dateStr: String): LocalTime { + return LocalTime.parse(dateStr, DateTimeFormatter.ofPattern("HH:mm:ss")) + } + + /** + * LocalTime 转 时分秒 00:00:00 + */ + fun localTime2Str(date: LocalTime): String { + return date.format(DateTimeFormatter.ofPattern("HH:mm:ss")) + } + + /** + * LocalTime 转 时分 00:00 + */ + fun localTime2HourMinuteStr(date: LocalTime): String { + return date.format(DateTimeFormatter.ofPattern("HH:mm")) + } + + /** + * 2025-06-17 18:18:23 转2025-06-17 + */ + fun str2str(dateStr: String): String { + if (dateStr.isEmpty()) { + return "" + } + val localDate = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + return localDate2Str(localDate) + } + + /** + * 将输入的时间字符串或时间戳转换为人性化显示 + * @param input 支持的格式: + * 1. yyyy-MM-dd(本地时间) + * 2. yyyy-MM-dd HH:mm:ss(本地时间) + * 3. 时间戳(秒或毫秒,UTC) + * 4. 格林威治时间(GMT,带时区) + * @return 格式化后的时间字符串,解析失败返回null + */ + fun formatDateTime(input: String?): String? { + if (input.isNullOrBlank()) return null + + // 解析输入为带时区的时间(关键:保留时区信息) + val zonedDateTime = parseToZonedDateTime(input) ?: return null + + // 转换为本地时区的时间(统一时区后处理) + val localDateTime = + zonedDateTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() + val inputDate = localDateTime.toLocalDate() + val today = LocalDate.now() + val currentYear = today.year + + // 计算与今天的天数差(输入日期 -> 今天) + val daysDifference = ChronoUnit.DAYS.between(inputDate, today) + + return when { + // 今天 + daysDifference == 0L -> "今天" + + // 本周内(过去7天) + daysDifference in 1..6 -> getWeekDayName(inputDate.dayOfWeek) + + // 本月内(超过一周但在本月) + inputDate.month == today.month && inputDate.year == today.year -> + "本月${inputDate.dayOfMonth}日" + + // 一年内(超过本月但在一年内) + inputDate.year == currentYear -> + "${inputDate.month.value}月${inputDate.dayOfMonth}日" + + // 超过一年 + else -> + "${inputDate.year}年${inputDate.month.value}月${inputDate.dayOfMonth}日" + } + } + + /** + * 解析输入为带时区的ZonedDateTime(核心修复) + */ + private fun parseToZonedDateTime(input: String): ZonedDateTime? { + // 1. 尝试解析时间戳(秒或毫秒,UTC时区) + try { + val timestamp = input.toLong() + val epochSecond = if (timestamp > 1e12) timestamp / 1000 else timestamp + val nano = if (timestamp > 1e12) (timestamp % 1000 * 1_000_000).toInt() else 0 + return ZonedDateTime.ofInstant( + Instant.ofEpochSecond(epochSecond, nano.toLong()), + ZoneOffset.UTC + ) + } catch (e: NumberFormatException) { + // 不是时间戳,继续尝试 + } + + // 2. 定义格式器分组(区分带时区和不带时区的格式) + // 带时区的格式(如GMT) + val zoneFormatters = listOf( + DateTimeFormatter.RFC_1123_DATE_TIME, + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz") + ) + // 不带时区的格式(本地时间) + val localFormatters = listOf( + DateTimeFormatter.ISO_LOCAL_DATE_TIME, // yyyy-MM-ddTHH:mm:ss + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ISO_LOCAL_DATE // yyyy-MM-dd + ) + + // 尝试解析带时区的格式(直接获取时区信息) + for (formatter in zoneFormatters) { + try { + return ZonedDateTime.parse(input, formatter) + } catch (e: DateTimeParseException) { + // 解析失败,继续尝试 + } + } + + // 尝试解析本地时间格式(默认使用系统时区) + for (formatter in localFormatters) { + try { + val localDateTime = if (formatter == DateTimeFormatter.ISO_LOCAL_DATE) { + LocalDate.parse(input, formatter).atStartOfDay() + } else { + LocalDateTime.parse(input, formatter) + } + // 关键修复:本地时间格式默认关联系统时区 + return ZonedDateTime.of(localDateTime, ZoneId.systemDefault()) + } catch (e: DateTimeParseException) { + // 解析失败,继续尝试 + } + } + + // 所有格式解析失败 + return null + } + + /** + * 将DayOfWeek转换为中文星期名称 + */ + private fun getWeekDayName(dayOfWeek: DayOfWeek): String { + return when (dayOfWeek) { + DayOfWeek.MONDAY -> "星期一" + DayOfWeek.TUESDAY -> "星期二" + DayOfWeek.WEDNESDAY -> "星期三" + DayOfWeek.THURSDAY -> "星期四" + DayOfWeek.FRIDAY -> "星期五" + DayOfWeek.SATURDAY -> "星期六" + DayOfWeek.SUNDAY -> "星期日" + } + } + + /** + * 格式化时间范围 + * 如果是同一天:格式为 2024-01-15 14:00—16:00 + * 如果是不同天:格式为 2024-01-15 14:00—2024-01-16 16:00 + * + * @param startTime 开始时间(秒时间戳) + * @param endTime 结束时间(秒时间戳) + * @return 格式化后的时间字符串 + */ + fun formatTimeRange(startTime: Long, endTime: Long): String { + + if (startTime == endTime && startTime == 0L) { + return "无" + } + + val startInstant = Instant.ofEpochSecond(startTime) + val endInstant = Instant.ofEpochSecond(endTime) + + val startLocalDateTime = LocalDateTime.ofInstant(startInstant, ZoneId.systemDefault()) + val endLocalDateTime = LocalDateTime.ofInstant(endInstant, ZoneId.systemDefault()) + + val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.getDefault()) + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault()) + val fullFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.getDefault()) + + return if (startLocalDateTime.toLocalDate() == endLocalDateTime.toLocalDate()) { + // 同一天 + val dateStr = startLocalDateTime.format(dateFormatter) + val startTimeStr = startLocalDateTime.format(timeFormatter) + val endTimeStr = endLocalDateTime.format(timeFormatter) + "$dateStr $startTimeStr—$endTimeStr" + } else { + // 不同天 + val startStr = startLocalDateTime.format(fullFormatter) + val endStr = endLocalDateTime.format(fullFormatter) + "$startStr—$endStr" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/DebounceClickHandler.kt b/app/src/main/java/com/yzx/kiosk/utils/DebounceClickHandler.kt new file mode 100644 index 0000000..42cc25e --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/DebounceClickHandler.kt @@ -0,0 +1,20 @@ +package com.yzx.kiosk.utils + +import kotlin.collections.set + +class DebounceClickHandler( + private val debounceTimeMillis: Long = 500L +) { + private var lastClickTimeMap = mutableMapOf() + + fun canClick(key: String = "default"): Boolean { + val currentTime = System.currentTimeMillis() + val lastClickTime = lastClickTimeMap[key] ?: 0L + return if (currentTime - lastClickTime > debounceTimeMillis) { + lastClickTimeMap[key] = currentTime + true + } else { + false + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/utils/FileUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/FileUtils.kt new file mode 100644 index 0000000..ac999f6 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/FileUtils.kt @@ -0,0 +1,194 @@ +package com.yzx.kiosk.utils + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.util.Base64 +import android.webkit.MimeTypeMap +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +object FileUtils { + + fun getFilePart(paramName: String, file: File): MultipartBody.Part { + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + return MultipartBody.Part.createFormData(paramName, file.name, requestFile) + } + + fun copyToFileCache(context: Context, sourceUri: Uri): Uri? { + val contentResolver = context.contentResolver + + // 获取文件扩展名,尝试保留原始文件类型 + val fileExtension = MimeTypeMap.getSingleton() + .getExtensionFromMimeType(contentResolver.getType(sourceUri)) ?: "tmp" + + val fileName = "copied_${System.currentTimeMillis()}.$fileExtension" + val destFile = File(context.cacheDir, fileName) + + return try { + contentResolver.openInputStream(sourceUri).use { inputStream -> + FileOutputStream(destFile).use { outputStream -> + inputStream?.copyTo(outputStream) ?: return null + } + } + Uri.fromFile(destFile) + } catch (e: IOException) { + e.printStackTrace() + null + } + } + + fun deleteFile(file: File) { + if (file.exists()) { + file.delete() + } + } + + + fun copyToFileCacheByAliyun(context: Context, sourceUri: Uri): Uri? { + val contentResolver = context.contentResolver + // 获取文件扩展名,尝试保留原始文件类型 + val fileExtension = MimeTypeMap.getSingleton() + .getExtensionFromMimeType(contentResolver.getType(sourceUri)) ?: "tmp" + val fileName = "${System.currentTimeMillis()}_original.$fileExtension" + val destFile = File(context.cacheDir, fileName) + + return try { + contentResolver.openInputStream(sourceUri).use { inputStream -> + FileOutputStream(destFile).use { outputStream -> + inputStream?.copyTo(outputStream) ?: return null + } + } + Uri.fromFile(destFile) + } catch (e: IOException) { + e.printStackTrace() + null + } + } + + + // 添加获取文件扩展名的方法 + fun getFileExtension(fileName: String): String { + return if (fileName.contains(".")) { + fileName.substring(fileName.lastIndexOf(".") + 1) + } else { + "" + } + } + + // Base64 编码/解码扩展 + fun String.encodeBase64ForRoute(): String { + val bytes = this.toByteArray(Charsets.UTF_8) + return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + .replace("=", "_EQUALS_") + } + + fun String.decodeBase64FromRoute(): String { + val base64 = this.replace("_EQUALS_", "=") + val bytes = Base64.decode(base64, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + return String(bytes, Charsets.UTF_8) + } + + /** + * 格式化文件大小(自动选择合适单位) + * @param sizeInBytes 文件大小(字节) + * @return 格式化后的字符串(如:1.23 MB) + */ + fun formatFileSize(sizeInBytes: Long): String { + if (sizeInBytes <= 0) return "0 B" + + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (Math.log10(sizeInBytes.toDouble()) / Math.log10(1024.0)).toInt() + val adjustedSize = sizeInBytes / Math.pow(1024.0, digitGroups.toDouble()) + + return "%.2f %s".format( + adjustedSize, + units[digitGroups.coerceAtMost(units.size - 1)] + ) + } + + fun saveFileToGallery( + context: Context, + filePath: String, + fileName: String, + mimeType: String, + isVideo: Boolean + ): Uri? { + val contentResolver = context.contentResolver + val now = System.currentTimeMillis() / 1000 + + val collection = if (isVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeType) + + // Android 10+ 推荐写相对路径 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val dir = if (isVideo) "Movies/随心瞰商家版" else "Pictures/随心瞰商家版" + put(MediaStore.MediaColumns.RELATIVE_PATH, dir) + put(MediaStore.MediaColumns.DATE_TAKEN, now) + } + } + + val uri = contentResolver.insert(collection, values) ?: return null + + try { + // 把你下载好的文件 copy 进 MediaStore 的输出流 + context.contentResolver.openOutputStream(uri)?.use { out -> + File(filePath).inputStream().use { input -> + input.copyTo(out) + } + } + return uri + } catch (e: Exception) { + e.printStackTrace() + // 出错记得删除那条空记录 + contentResolver.delete(uri, null, null) + } + return null + } + + fun saveBitmapToGallery( + context: Context, + bitmap: Bitmap, + fileName: String = "IMG_${System.currentTimeMillis()}.jpg" + ): Boolean { + var saveSuccess = false + + // Android 10 以上使用 MediaStore + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/随心瞰商家版") // 自定义相册文件夹 + put(MediaStore.Images.Media.IS_PENDING, 1) + } + + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: return false + + resolver.openOutputStream(uri)?.use { outputStream -> + saveSuccess = bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + } + + // 标记文件已经写入完成 + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + + return saveSuccess + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/GlideEngine.kt b/app/src/main/java/com/yzx/kiosk/utils/GlideEngine.kt new file mode 100644 index 0000000..27fb787 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/GlideEngine.kt @@ -0,0 +1,115 @@ +package com.yzx.kiosk.utils + +import android.content.Context +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.CenterCrop +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.luck.picture.lib.engine.ImageEngine +import com.luck.picture.lib.utils.ActivityCompatHelper +import com.yzx.kiosk.R + + +/** + * @author:luck + * @date:2019-11-13 17:02 + * @describe:Glide加载引擎 + */ +class GlideEngine private constructor() : ImageEngine { + /** + * 加载图片 + * + * @param context 上下文 + * @param url 资源url + * @param imageView 图片承载控件 + */ + override fun loadImage(context: Context, url: String?, imageView: ImageView) { + if (!ActivityCompatHelper.assertValidRequest(context)) { + return + } + Glide.with(context) + .load(url) + .into(imageView) + } + + override fun loadImage( + context: Context, + imageView: ImageView, + url: String?, + maxWidth: Int, + maxHeight: Int + ) { + if (!ActivityCompatHelper.assertValidRequest(context)) { + return + } + Glide.with(context) + .load(url) + .override(maxWidth, maxHeight) + .into(imageView) + } + + /** + * 加载相册目录封面 + * + * @param context 上下文 + * @param url 图片路径 + * @param imageView 承载图片ImageView + */ + override fun loadAlbumCover(context: Context, url: String?, imageView: ImageView) { + if (!ActivityCompatHelper.assertValidRequest(context)) { + return + } + Glide.with(context) + .asBitmap() + .load(url) + .override(180, 180) + .sizeMultiplier(0.5f) + .transform(CenterCrop(), RoundedCorners(8)) + .placeholder(R.mipmap.img_square_loading_big) + .into(imageView) + } + + + /** + * 加载图片列表图片 + * + * @param context 上下文 + * @param url 图片路径 + * @param imageView 承载图片ImageView + */ + override fun loadGridImage(context: Context, url: String?, imageView: ImageView) { + if (!ActivityCompatHelper.assertValidRequest(context)) { + return + } + Glide.with(context) + .load(url) + .override(200, 200) + .centerCrop() + .placeholder(R.mipmap.img_square_loading_big) + .into(imageView) + } + + override fun pauseRequests(context: Context) { + if (!ActivityCompatHelper.assertValidRequest(context)) { + return + } + Glide.with(context).pauseRequests() + } + + override fun resumeRequests(context: Context) { + if (!ActivityCompatHelper.assertValidRequest(context)) { + return + } + Glide.with(context).resumeRequests() + } + + private object InstanceHolder { + val instance: GlideEngine = GlideEngine() + } + + companion object { + fun createGlideEngine(): GlideEngine { + return InstanceHolder.instance + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/InactivityManager.kt b/app/src/main/java/com/yzx/kiosk/utils/InactivityManager.kt new file mode 100644 index 0000000..9cb0a06 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/InactivityManager.kt @@ -0,0 +1,146 @@ +package com.yzx.kiosk.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 无操作超时管理器 + * 检测用户是否30秒无操作,触发海报页面显示 + * 仅在首页无操作时显示海报 + */ +@Singleton +class InactivityManager @Inject constructor() { + + companion object { + private const val INACTIVITY_TIMEOUT = 60_000L // 60秒 + } + + private val _shouldShowPoster = MutableStateFlow(false) + val shouldShowPoster: StateFlow = _shouldShowPoster.asStateFlow() + + private var inactivityJob: Job? = null + private var scope: CoroutineScope? = null + + // 是否有正在进行的任务(下载或打印) + private var hasActiveTask = false + + // 当前是否在首页 + private var isOnHomePage = true + + // 当前打印状态 + private var currentPrintingStatus: String = "当前无小程序用户操作打印。打印时,不影响当前屏幕操作" + + // 默认打印状态文本 + private val DEFAULT_PRINTING_STATUS = "当前无小程序用户操作打印。打印时,不影响当前屏幕操作" + + /** + * 初始化,传入协程作用域 + */ + fun init(coroutineScope: CoroutineScope) { + scope = coroutineScope + resetTimer() + } + + /** + * 用户有操作,重置计时器 + */ + fun onUserActivity() { + _shouldShowPoster.value = false + resetTimer() + } + + /** + * 设置是否有活动任务(下载或打印中) + */ + fun setActiveTask(active: Boolean) { + hasActiveTask = active + if (active) { + // 有任务时停止计时 + inactivityJob?.cancel() + } else { + // 任务完成后重新开始计时 + resetTimer() + } + } + + /** + * 隐藏海报页面 + */ + fun hidePoster() { + _shouldShowPoster.value = false + resetTimer() + } + + /** + * 设置当前是否在首页 + */ + fun setOnHomePage(onHome: Boolean) { + isOnHomePage = onHome + if (!onHome) { + // 离开首页时停止计时并隐藏海报 + inactivityJob?.cancel() + _shouldShowPoster.value = false + } else { + // 回到首页时重新开始计时 + resetTimer() + } + } + + /** + * 更新打印状态 + * 当首页顶部信息变化时,重置倒计时 + * 当显示"当前无小程序用户操作打印"时,开始倒计时 + */ + fun updatePrintingStatus(status: String) { + currentPrintingStatus = status + val isDefaultStatus = status.contains("当前无小程序用户操作打印") + + // 状态变化时,重置倒计时 + inactivityJob?.cancel() + + // 只有在首页、没有活动任务、且是默认状态时,才启动倒计时 + if (isOnHomePage && !hasActiveTask && isDefaultStatus) { + inactivityJob = scope?.launch { + delay(INACTIVITY_TIMEOUT) + _shouldShowPoster.value = true + } + } else { + // 非默认状态时,停止倒计时并隐藏海报 + _shouldShowPoster.value = false + } + } + + /** + * 重置计时器 + */ + private fun resetTimer() { + inactivityJob?.cancel() + + // 只有在首页、没有活动任务、且是默认打印状态时,才启动计时器 + if (hasActiveTask || !isOnHomePage) return + + val isDefaultStatus = currentPrintingStatus.contains("当前无小程序用户操作打印") + if (!isDefaultStatus) return + + inactivityJob = scope?.launch { + delay(INACTIVITY_TIMEOUT) + _shouldShowPoster.value = true + } + } + + /** + * 销毁 + */ + fun destroy() { + inactivityJob?.cancel() + scope = null + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/utils/LogUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/LogUtils.kt new file mode 100644 index 0000000..d140289 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/LogUtils.kt @@ -0,0 +1,61 @@ +package com.yzx.kiosk.utils + +import android.util.Log + +object LogUtils { + + private var isDebugMode = false + private val defaultTag = "Zhifly" + + fun init(isDebug: Boolean = false) { + isDebugMode = isDebug + } + + fun e(message: String) { + if (isDebugMode) { + Log.e(defaultTag, message) + } + } + + fun e(message: String, exception: Throwable) { + if (isDebugMode) { + Log.e(defaultTag, message, exception) + } + } + + fun e(tag: String, message: String) { + if (isDebugMode) { + Log.e(tag, message) + } + } + + fun i(message: String) { + if (isDebugMode) { + Log.i(defaultTag, message) + } + } + + fun i(tag: String, message: String) { + if (isDebugMode) { + Log.i(tag, message) + } + } + + fun w(tag: String, message: String) { + if (isDebugMode) { + Log.w(tag, message) + } + } + + fun d(message: String) { + if (isDebugMode) { + Log.d(defaultTag, message) + } + } + + fun d(tag: String, message: String) { + if (isDebugMode) { + Log.d(tag, message) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/MMKVUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/MMKVUtils.kt new file mode 100644 index 0000000..624eb5c --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/MMKVUtils.kt @@ -0,0 +1,155 @@ +package com.yzx.kiosk.utils + +import android.app.Application +import android.os.Parcelable +import com.google.gson.Gson +import com.tencent.mmkv.MMKV +import kotlin.collections.filter +import kotlin.collections.toTypedArray +import kotlin.jvm.java +import kotlin.text.isNotEmpty +import kotlin.text.startsWith + +object MMKVUtils { + + private var isInitialized = false + + private val defaultMMKV by lazy { + checkInitialization() + MMKV.defaultMMKV() + } + + fun init(application: Application): String { + val rootDir = MMKV.initialize(application) + isInitialized = true + return rootDir + } + + private fun checkInitialization() { + if (!isInitialized) { + throw IllegalStateException("MMKVUtils not initialized, call init() first") + } + } + + fun putBoolean(key: String, value: Boolean) { + defaultMMKV.encode(key, value) + } + + fun getBoolean(key: String, defaultValue: Boolean = false): Boolean { + return defaultMMKV.decodeBool(key, defaultValue) + } + + fun putInt(key: String, value: Int) { + defaultMMKV.encode(key, value) + } + + fun getInt(key: String, defaultValue: Int = 0): Int { + return defaultMMKV.decodeInt(key, defaultValue) + } + + fun putLong(key: String, value: Long) { + defaultMMKV.encode(key, value) + } + + fun getLong(key: String, defaultValue: Long = 0L): Long { + return defaultMMKV.decodeLong(key, defaultValue) + } + + fun putFloat(key: String, value: Float) { + defaultMMKV.encode(key, value) + } + + fun getFloat(key: String, defaultValue: Float = 0f): Float { + return defaultMMKV.decodeFloat(key, defaultValue) + } + + fun putDouble(key: String, value: Double) { + defaultMMKV.encode(key, value) + } + + fun getDouble(key: String, defaultValue: Double = 0.0): Double { + return defaultMMKV.decodeDouble(key, defaultValue) + } + + fun putString(key: String, value: String?) { + defaultMMKV.encode(key, value) + } + + fun getString(key: String, defaultValue: String = ""): String { + return defaultMMKV.decodeString(key, defaultValue) ?: defaultValue + } + + fun putBytes(key: String, value: ByteArray?) { + defaultMMKV.encode(key, value) + } + + fun getBytes(key: String): ByteArray? { + return defaultMMKV.decodeBytes(key) + } + + fun putParcelable(key: String, value: T?) { + defaultMMKV.encode(key, value) + } + + fun getParcelable(key: String, clazz: Class): T? { + return defaultMMKV.decodeParcelable(key, clazz) + } + + inline fun putObject(key: String, value: T) { + putString(key, Gson().toJson(value, T::class.java)) + } + + inline fun getObject(key: String): T? { + val json = getString(key, "") + return if (json.isNotEmpty()) { + try { + Gson().fromJson(json, T::class.java) + } catch (e: Exception) { + null + } + } else { + null + } + } + + fun containsKey(key: String): Boolean { + return defaultMMKV.containsKey(key) + } + + fun remove(key: String) { + defaultMMKV.removeValueForKey(key) + } + + fun removeValuesForKeys(keyPrefix: String) { + val keys = defaultMMKV.allKeys() + if (keys != null) { + val keysToRemove = keys.filter { it.startsWith(keyPrefix) }.toTypedArray() + defaultMMKV.removeValuesForKeys(keysToRemove) + } + } + + + // 专门处理List类型的方法 + inline fun putList(key: String, value: List) { + val json = Gson().toJson(value) + putString(key, json) + } + + inline fun getList(key: String): List? { + val json = getString(key, "") + return if (json.isNotEmpty()) { + try { + val type = object : com.google.gson.reflect.TypeToken>() {}.type + Gson().fromJson>(json, type) + } catch (e: Exception) { + null + } + } else { + null + } + } + + + + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/Md5Utils.kt b/app/src/main/java/com/yzx/kiosk/utils/Md5Utils.kt new file mode 100644 index 0000000..1d6ddc3 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/Md5Utils.kt @@ -0,0 +1,15 @@ +package com.yzx.kiosk.utils + +import java.security.MessageDigest + +object Md5Utils { + /** + * MD5加密 + */ + fun md5(input: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(input.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/utils/NavigationBarUtil.kt b/app/src/main/java/com/yzx/kiosk/utils/NavigationBarUtil.kt new file mode 100644 index 0000000..60fe96e --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/NavigationBarUtil.kt @@ -0,0 +1,46 @@ +package com.yzx.kiosk.utils + +import android.app.Activity +import android.os.Build +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +object NavigationBarUtil { + + val SUPPORT_EDGE_TO_EDGE_VERSION = Build.VERSION_CODES.VANILLA_ICE_CREAM//15+ + + @JvmField + val isEdgeToEdgeSupported = Build.VERSION.SDK_INT >= SUPPORT_EDGE_TO_EDGE_VERSION + + /** + * 适配底部导航栏 : 给根布局添加底部导航栏的padding + */ + fun applyNavigationBarPadding(activity: Activity) { + if (isEdgeToEdgeSupported) { + //Edge-to-Edge 模式适配 + val rootView = activity.window.decorView.findViewById(android.R.id.content) + if (rootView != null) { + applyNavigationBarInsets(rootView) + } + } + } + + + /** + * 给view添加一个底部导航栏高度的padding + */ + fun applyNavigationBarInsets(view: View) { + val initialPaddingBottom = view.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(view) { v: View, insets: WindowInsetsCompat -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding( + v.paddingLeft, + v.paddingTop, + v.paddingRight, + initialPaddingBottom + systemBars.bottom + ) + insets + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/QrCodeUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/QrCodeUtils.kt new file mode 100644 index 0000000..d1fc761 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/QrCodeUtils.kt @@ -0,0 +1,60 @@ +package com.yzx.kiosk.utils +import android.graphics.* +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import androidx.core.graphics.createBitmap + +object QrCodeUtils { + + fun generateStyledQrBitmap( + content: String, + size: Int = 480, + qrColor: Int = Color.BLACK, + bgColor: Int = Color.WHITE, + logo: Bitmap? = null, + logoSizeRatio: Float = 0.23f, + cornerRadius: Float = 32f // 外部圆角 + ): Bitmap { + val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size) + val bmp = createBitmap(size, size) + val paint = Paint().apply { isAntiAlias = true } + + // 先用canvas画圆角背景 + val canvas = Canvas(bmp) + val rectF = RectF(0f, 0f, size.toFloat(), size.toFloat()) + paint.color = bgColor + canvas.drawRoundRect(rectF, cornerRadius, cornerRadius, paint) + + // 再画二维码点 + paint.color = qrColor + for (x in 0 until size) { + for (y in 0 until size) { + if (bitMatrix[x, y]) { + // 可选:点形状自定义(圆点/正方) + canvas.drawCircle( + x + 0.5f, y + 0.5f, 0.75f, paint // 圆点(更柔和) + ) + // canvas.drawPoint(x.toFloat(), y.toFloat(), paint) // 普通正方点 + } + } + } + + // 加logo + logo?.let { + val scale = size * logoSizeRatio / it.width + val matrix = Matrix().apply { + postScale(scale, scale) + postTranslate( + (size - it.width * scale) / 2, + (size - it.height * scale) / 2 + ) + } + paint.reset() + paint.isAntiAlias = true + canvas.drawBitmap(it, matrix, paint) + } + + return bmp + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/SimplePcmPlayer.kt b/app/src/main/java/com/yzx/kiosk/utils/SimplePcmPlayer.kt new file mode 100644 index 0000000..23bf756 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/SimplePcmPlayer.kt @@ -0,0 +1,47 @@ +package com.yzx.kiosk.utils + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack + +class SimplePcmPlayer { + + private val sampleRate = 16000 + private val channelConfig = AudioFormat.CHANNEL_OUT_MONO + private val audioFormat = AudioFormat.ENCODING_PCM_16BIT + + private val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat) + + private val audioTrack: AudioTrack = AudioTrack( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + AudioFormat.Builder() + .setEncoding(audioFormat) + .setSampleRate(sampleRate) + .setChannelMask(channelConfig) + .build(), + minBufferSize, + AudioTrack.MODE_STREAM, + AudioManager.AUDIO_SESSION_ID_GENERATE + ) + + fun play() { + audioTrack.play() + } + + fun write(data: ByteArray) { + audioTrack.write(data, 0, data.size) + } + + fun stop() { + audioTrack.stop() + audioTrack.flush() + } + + fun release() { + audioTrack.release() + } +} diff --git a/app/src/main/java/com/yzx/kiosk/utils/TimeUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/TimeUtils.kt new file mode 100644 index 0000000..5188528 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/TimeUtils.kt @@ -0,0 +1,14 @@ +package com.yzx.kiosk.utils + +import java.util.Locale + +object TimeUtils { + + //显示00:00:00 + fun formatTime(time: Long): String { + val hour = time / 3600 + val minute = time % 3600 / 60 + val second = time % 60 + return String.format(Locale.getDefault(), "%02d:%02d:%02d", hour, minute, second) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/ToastUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/ToastUtils.kt new file mode 100644 index 0000000..0478988 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/ToastUtils.kt @@ -0,0 +1,37 @@ +package com.yzx.kiosk.utils + +import android.app.Application +import androidx.annotation.StringRes +import com.hjq.toast.Toaster +import com.hjq.toast.style.BlackToastStyle + +object ToastUtils { + + fun init(application: Application) { + Toaster.init(application, BlackToastStyle()) + } + + fun show(text: CharSequence) { + Toaster.show(text) + } + + fun show(@StringRes resId: Int) { + Toaster.show(resId) + } + + fun showShort(text: CharSequence) { + Toaster.showShort(text) + } + + fun showShort(@StringRes resId: Int) { + Toaster.showShort(resId) + } + + fun showLong(text: CharSequence) { + Toaster.show(text) // Toaster.show() 默认是长显示 + } + + fun showLong(@StringRes resId: Int) { + Toaster.show(resId) // Toaster.show() 默认是长显示 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/ValidationUtil.kt b/app/src/main/java/com/yzx/kiosk/utils/ValidationUtil.kt new file mode 100644 index 0000000..447781b --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/ValidationUtil.kt @@ -0,0 +1,22 @@ +package com.yzx.kiosk.utils + +import kotlin.text.all +import kotlin.text.isDigit +import kotlin.text.isNotEmpty +import kotlin.text.matches + + +object ValidationUtil { + + fun isValidPhone(phone: String): Boolean { + return phone.isNotEmpty() && phone.length == 11 && phone.all { it.isDigit() } + } + + fun isValidSmsCode(code: String): Boolean { + return code.length == 4 && code.all { it.isDigit() } + } + + fun isValidEmail(email: String): Boolean { + return email.matches(Regex(".+@.+")) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt b/app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt new file mode 100644 index 0000000..25517b8 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt @@ -0,0 +1,811 @@ +package com.yzx.kiosk.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.core.content.FileProvider +import com.google.gson.Gson +import com.hjq.toast.Toaster +import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.network.di.NetworkModule.CLIENT_DOWNLOAD +import com.yzx.kiosk.network.model.response.VersionResponse +import com.yzx.kiosk.network.repository.NetWorkRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import javax.inject.Inject +import javax.inject.Named + +class VersionUpdateManager @Inject constructor( + private val repository: NetWorkRepository, + @ApplicationContext private val context: Context, + private val gson: Gson, + @Named(CLIENT_DOWNLOAD) private val okHttpClient: OkHttpClient +) { + companion object { + private const val TAG = "VersionUpdateManager" + private const val BUFFER_SIZE = 8192 // 8KB buffer + private const val PROGRESS_UPDATE_INTERVAL = 200L // 200ms 更新一次进度 + } + // 版本信息相关 + private val _latestVersion = MutableStateFlow(null) + val latestVersion: StateFlow = _latestVersion + + private val _showUpdateDialog = MutableStateFlow(false) + val showUpdateDialog: StateFlow = _showUpdateDialog + + private val _downloadProgress = MutableStateFlow(0) + val downloadProgress: StateFlow = _downloadProgress + + private val _isDownloading = MutableStateFlow(false) + val isDownloading: StateFlow = _isDownloading + + // 当前下载的文件路径 + private var currentDownloadFile: File? = null + + // 当前下载任务(用于取消下载) + private var currentDownloadJob: kotlinx.coroutines.Job? = null + + /** + * 检查版本更新(从设置页面点击升级时调用) + * @param scope 协程作用域 + * @param showToast 是否显示Toast提示(没有更新时) + */ + fun checkVersionUpdate(scope: CoroutineScope, showToast: Boolean = true) { + scope.launch(Dispatchers.IO) { + try { + val versionResult = repository.checkLatestVersion(8).first() + if (versionResult.code == 100000) { + val data = versionResult.data + + // 如果data为null,没有更新 + if (data == null) { + if (showToast) { + scope.launch(Dispatchers.Main) { + Toaster.show("已经是最新版本") + } + } + return@launch + } + + // 将data转换为JsonElement进行判断 + val jsonElement = gson.toJsonTree(data) + + // 如果data是空数组,没有更新 + if (jsonElement.isJsonArray && jsonElement.asJsonArray.isEmpty()) { + if (showToast) { + scope.launch(Dispatchers.Main) { + Toaster.show("已经是最新版本") + } + } + return@launch + } + + // 如果data是数组,遍历找出type == 8的对象 + val versionData = if (jsonElement.isJsonArray) { + val jsonArray = jsonElement.asJsonArray + jsonArray.firstOrNull { element -> + element.isJsonObject && element.asJsonObject.get("type")?.asInt == 8 + }?.let { element -> + gson.fromJson(element, VersionResponse::class.java) + } + } else if (jsonElement.isJsonObject) { + // 如果data是对象,直接解析 + val jsonObject = jsonElement.asJsonObject + // 检查type是否为8 + if (jsonObject.get("type")?.asInt == 8) { + gson.fromJson(jsonObject, VersionResponse::class.java) + } else { + null + } + } else { + null + } + + // 如果找不到type==8的对象,或者version_code小于当前APP versionCode + if (versionData == null || !isNeedUpdate(versionData.version_code)) { + if (showToast) { + scope.launch(Dispatchers.Main) { + Toaster.show("已经是最新版本") + } + } + return@launch + } + + // 有更新,显示对话框 + // 在显示对话框前,重置下载状态,确保是全新流程 + resetDownloadState() + _latestVersion.value = versionData + scope.launch(Dispatchers.Main) { + _showUpdateDialog.value = true + } + } else { + if (showToast) { + scope.launch(Dispatchers.Main) { + Toaster.show("版本检测失败: ${versionResult.message ?: "未知错误"}") + } + } + } + } catch (e: Exception) { + e.printStackTrace() + if (showToast) { + scope.launch(Dispatchers.Main) { + Toaster.show("版本检测失败: ${e.message}") + } + } + } + } + } + + /** + * 检查版本更新(APP启动后自动检查,不显示Toast) + * @param scope 协程作用域 + */ + fun checkVersionUpdateSilent(scope: CoroutineScope) { + scope.launch(Dispatchers.IO) { + try { + val versionResult = repository.checkLatestVersion(8).first() + if (versionResult.code == 100000) { + val data = versionResult.data + + // 如果data为null,不做任何提示 + if (data == null) { + return@launch + } + + // 将data转换为JsonElement进行判断 + val jsonElement = gson.toJsonTree(data) + + // 如果data是空数组,不做任何提示 + if (jsonElement.isJsonArray && jsonElement.asJsonArray.isEmpty()) { + return@launch + } + + // 如果data是数组,遍历找出type == 8的对象 + val versionData = if (jsonElement.isJsonArray) { + val jsonArray = jsonElement.asJsonArray + jsonArray.firstOrNull { element -> + element.isJsonObject && element.asJsonObject.get("type")?.asInt == 8 + }?.let { element -> + gson.fromJson(element, VersionResponse::class.java) + } + } else if (jsonElement.isJsonObject) { + // 如果data是对象,直接解析 + val jsonObject = jsonElement.asJsonObject + // 检查type是否为8 + if (jsonObject.get("type")?.asInt == 8) { + gson.fromJson(jsonObject, VersionResponse::class.java) + } else { + null + } + } else { + null + } + + // 如果找不到type==8的对象,或者version_code小于当前APP versionCode,不做任何提示 + if (versionData == null || !isNeedUpdate(versionData.version_code)) { + return@launch + } + + // 有更新,显示对话框 + // 在显示对话框前,重置下载状态,确保是全新流程 + resetDownloadState() + _latestVersion.value = versionData + scope.launch(Dispatchers.Main) { + _showUpdateDialog.value = true + } + } + } catch (e: Exception) { + e.printStackTrace() + // 静默检查,不显示错误提示 + } + } + } + + /** + * 判断是否需要更新 + */ + private fun isNeedUpdate(latestVersionCode: Int): Boolean { + return try { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val currentVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode.toInt() + } else { + packageInfo.versionCode + } + latestVersionCode > currentVersionCode + } catch (e: PackageManager.NameNotFoundException) { + e.printStackTrace() + false + } + } + + /** + * 获取当前版本名称 + */ + fun getCurrentVersionName(): String { + return try { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + packageInfo.versionName ?: BuildConfig.VERSION_NAME + } catch (e: PackageManager.NameNotFoundException) { + BuildConfig.VERSION_NAME + } + } + + /** + * 开始下载更新(使用 OkHttp) + */ + fun startDownload(scope: CoroutineScope) { + val versionResponse = _latestVersion.value ?: run { + LogUtils.e(TAG, "开始下载失败:版本信息不存在") + scope.launch(Dispatchers.Main) { + Toaster.show("版本信息不存在") + } + return + } + + // 取消之前的下载任务(如果存在) + currentDownloadJob?.cancel() + + LogUtils.d(TAG, "========== 开始下载APK (OkHttp) ==========") + LogUtils.d(TAG, "版本信息: version=${versionResponse.version}, version_code=${versionResponse.version_code}") + LogUtils.d(TAG, "下载URL: ${versionResponse.apk_url}") + + // 在主线程更新状态,确保UI立即响应 + scope.launch(Dispatchers.Main) { + _isDownloading.value = true + _downloadProgress.value = 0 + } + + // 保存下载任务,以便可以取消 + currentDownloadJob = scope.launch(Dispatchers.IO) { + try { + val apkUrl = versionResponse.apk_url + LogUtils.d(TAG, "下载URL: $apkUrl") + + // 验证URL格式 + if (apkUrl.isBlank()) { + LogUtils.e(TAG, "下载URL为空") + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 0 + Toaster.show("下载URL格式错误") + } + return@launch + } + + // 下载到应用私有目录 + val downloadDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + LogUtils.d(TAG, "下载目录: $downloadDir") + + val apkDir = File(downloadDir, "apk") + if (!apkDir.exists()) { + val created = apkDir.mkdirs() + LogUtils.d(TAG, "创建APK目录: $apkDir, 成功: $created") + } + + val apkFile = File(apkDir, "update_${versionResponse.version}.apk") + LogUtils.d(TAG, "目标文件路径: ${apkFile.absolutePath}") + + // 保存当前下载文件路径 + currentDownloadFile = apkFile + + // 检查文件是否已存在 + if (apkFile.exists() && apkFile.length() > 0) { + LogUtils.d(TAG, "检测到已存在的APK文件,大小: ${apkFile.length()}字节") + + // 先获取服务器文件大小进行验证 + try { + val headRequest = Request.Builder() + .url(apkUrl) + .head() // HEAD请求只获取响应头,不下载内容 + .addHeader("User-Agent", "Mozilla/5.0") + .build() + + val headResponse = okHttpClient.newCall(headRequest).execute() + val serverFileSize = headResponse.header("Content-Length")?.toLongOrNull() ?: -1L + headResponse.close() + + LogUtils.d(TAG, "服务器文件大小: ${if (serverFileSize > 0) "${serverFileSize}字节" else "未知"}") + + // 如果服务器文件大小已知,且与本地文件大小匹配,直接安装 + if (serverFileSize > 0 && apkFile.length() == serverFileSize) { + LogUtils.d(TAG, "本地文件大小与服务器匹配,直接安装") + + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 100 + } + + // 直接安装 + withContext(Dispatchers.IO) { + try { + installApk(apkFile) + LogUtils.d(TAG, "已启动安装流程") + } catch (e: Exception) { + LogUtils.e(TAG, "启动安装失败: ${e.message}") + e.printStackTrace() + withContext(Dispatchers.Main) { + Toaster.show("启动安装失败: ${e.message}") + } + } + } + return@launch + } else if (serverFileSize > 0 && apkFile.length() != serverFileSize) { + LogUtils.d(TAG, "本地文件大小与服务器不匹配,删除旧文件重新下载") + // 文件大小不匹配,删除旧文件 + try { + apkFile.delete() + } catch (e: Exception) { + LogUtils.w(TAG, "删除旧文件失败: ${e.message}") + } + } else { + // 无法获取服务器文件大小,但本地文件存在,询问是否使用 + // 这里我们选择直接使用本地文件(假设它是完整的) + LogUtils.d(TAG, "无法获取服务器文件大小,使用本地文件") + + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 100 + } + + // 直接安装 + withContext(Dispatchers.IO) { + try { + installApk(apkFile) + LogUtils.d(TAG, "已启动安装流程") + } catch (e: Exception) { + LogUtils.e(TAG, "启动安装失败: ${e.message}") + e.printStackTrace() + withContext(Dispatchers.Main) { + Toaster.show("启动安装失败: ${e.message}") + } + } + } + return@launch + } + } catch (e: Exception) { + LogUtils.w(TAG, "检查服务器文件大小失败: ${e.message},继续下载流程") + // 如果HEAD请求失败,删除旧文件重新下载 + try { + apkFile.delete() + } catch (deleteException: Exception) { + LogUtils.w(TAG, "删除旧文件失败: ${deleteException.message}") + } + } + } else { + // 文件不存在,清除目录下的其他旧文件 + try { + val files = apkDir.listFiles() + if (files != null && files.isNotEmpty()) { + LogUtils.d(TAG, "清除APK目录下的旧文件,共 ${files.size} 个文件") + var deletedCount = 0 + for (file in files) { + try { + if (file.delete()) { + deletedCount++ + } + } catch (e: Exception) { + LogUtils.w(TAG, "删除文件失败: ${file.name}, ${e.message}") + } + } + LogUtils.d(TAG, "已清除 $deletedCount 个文件") + } + } catch (e: Exception) { + LogUtils.e(TAG, "清除APK目录文件异常: ${e.message}") + } + } + + // 检查网络连接 + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = connectivityManager.activeNetwork + val capabilities = network?.let { connectivityManager.getNetworkCapabilities(it) } + val isConnected = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + LogUtils.d(TAG, "网络状态检查: isConnected=$isConnected") + + if (!isConnected) { + LogUtils.e(TAG, "网络未连接") + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 0 + Toaster.show("网络未连接,请检查网络设置") + } + return@launch + } + + // 创建 OkHttp 请求 + val request = Request.Builder() + .url(apkUrl) + .addHeader("User-Agent", "Mozilla/5.0") + .get() + .build() + + LogUtils.d(TAG, "开始执行下载请求...") + + // 执行下载 + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorMsg = "下载失败: HTTP ${response.code} ${response.message}" + LogUtils.e(TAG, errorMsg) + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 0 + Toaster.show(errorMsg) + } + return@launch + } + + val responseBody = response.body + if (responseBody == null) { + LogUtils.e(TAG, "响应体为空") + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 0 + Toaster.show("下载失败: 响应体为空") + } + return@launch + } + + val totalSize = responseBody.contentLength() + LogUtils.d(TAG, "文件总大小: ${if (totalSize > 0) "${totalSize}字节" else "未知"}") + + // 创建文件输出流 + apkFile.parentFile?.mkdirs() + var inputStream: InputStream? = null + var outputStream: FileOutputStream? = null + var downloadedSize = 0L + var lastProgressUpdateTime = System.currentTimeMillis() + + try { + inputStream = responseBody.byteStream() + outputStream = FileOutputStream(apkFile) + + val buffer = ByteArray(BUFFER_SIZE) + var bytesRead: Int + var lastProgress = 0 + var lastDownloadedSize = 0L + + // 下载开始时立即更新进度为0 + withContext(Dispatchers.Main) { + _downloadProgress.value = 0 + } + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + // 检查是否被取消 + if (currentDownloadJob?.isActive != true) { + LogUtils.d(TAG, "下载被取消") + break + } + + outputStream.write(buffer, 0, bytesRead) + downloadedSize += bytesRead + + // 更新进度 - 更频繁地检查,确保进度条能及时更新 + val currentTime = System.currentTimeMillis() + val shouldUpdate = currentTime - lastProgressUpdateTime >= PROGRESS_UPDATE_INTERVAL || + downloadedSize == totalSize || + (totalSize > 0 && downloadedSize - lastDownloadedSize >= totalSize / 100) // 每1%更新一次 + + if (shouldUpdate) { + val progress = if (totalSize > 0) { + ((downloadedSize * 100) / totalSize).toInt().coerceIn(0, 100) + } else { + // 不知道总大小,根据已下载大小估算进度 + // 假设文件至少1MB,每下载1MB增加1%(最多到95%) + val estimatedProgress = minOf((downloadedSize / (1024 * 1024)).toInt(), 95) + maxOf(estimatedProgress, lastProgress) + } + + // 只要进度有变化就更新,或者时间间隔到了也更新(即使进度没变,也更新一次以保持UI响应) + if (progress != lastProgress || currentTime - lastProgressUpdateTime >= PROGRESS_UPDATE_INTERVAL) { + lastProgress = progress + lastDownloadedSize = downloadedSize + lastProgressUpdateTime = currentTime + LogUtils.d(TAG, "下载进度: $progress% (已下载: ${downloadedSize}字节/${if (totalSize > 0) "${totalSize}字节" else "未知"})") + + withContext(Dispatchers.Main) { + _downloadProgress.value = progress + } + } + } + } + + outputStream.flush() + LogUtils.d(TAG, "下载完成,文件大小: ${apkFile.length()}字节, 已下载: ${downloadedSize}字节") + + // 验证文件完整性 + if (totalSize > 0 && apkFile.length() != totalSize) { + LogUtils.e(TAG, "文件大小不匹配: 期望=${totalSize}字节, 实际=${apkFile.length()}字节") + apkFile.delete() + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 0 + Toaster.show("下载文件损坏,请重试") + } + return@launch + } + + // 下载成功,立即更新进度到100%(确保UI显示100%) + withContext(Dispatchers.Main) { + _downloadProgress.value = 100 + _isDownloading.value = false + } + LogUtils.d(TAG, "下载成功,进度已更新到100%") + + // 启动安装 + withContext(Dispatchers.IO) { + try { + installApk(apkFile) + } catch (e: Exception) { + LogUtils.e(TAG, "启动安装失败: ${e.message}") + e.printStackTrace() + withContext(Dispatchers.Main) { + Toaster.show("启动安装失败: ${e.message}") + } + } + } + + } catch (e: IOException) { + LogUtils.e(TAG, "下载IO异常: ${e.message}") + e.printStackTrace() + + // 清理不完整的文件 + try { + if (apkFile.exists()) { + apkFile.delete() + } + } catch (deleteException: Exception) { + LogUtils.e(TAG, "删除不完整文件失败: ${deleteException.message}") + } + + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 0 + Toaster.show("下载失败: ${e.message ?: "IO错误"}") + } + } finally { + try { + inputStream?.close() + outputStream?.close() + } catch (e: Exception) { + LogUtils.e(TAG, "关闭流异常: ${e.message}") + } + } + } + + LogUtils.d(TAG, "========== 下载完成 ==========") + } catch (e: Exception) { + LogUtils.e(TAG, "下载过程异常: ${e.message}") + e.printStackTrace() + + // 清理不完整的下载文件 + currentDownloadFile?.takeIf { it.exists() }?.let { file -> + try { + val deleted = file.delete() + LogUtils.d(TAG, "删除异常下载的文件: ${file.absolutePath}, 成功: $deleted") + } catch (deleteException: Exception) { + LogUtils.e(TAG, "删除异常下载文件失败: ${deleteException.message}") + } + } + + withContext(Dispatchers.Main) { + _isDownloading.value = false + _downloadProgress.value = 0 + Toaster.show("下载异常: ${e.message ?: "未知错误"}") + } + } + } + } + + /** + * 安装APK(使用FileProvider) + */ + private fun installApk(apkFile: File) { + if (!apkFile.exists()) { + Toaster.show("安装文件不存在") + return + } + + val apkUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // Android 7.0+ 使用FileProvider + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + } else { + // Android 7.0以下直接使用file:// + Uri.fromFile(apkFile) + } + + installApkInternal(apkUri) + } + + /** + * 执行安装APK + */ + private fun installApkInternal(apkUri: Uri) { + val intent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + setDataAndType(apkUri, "application/vnd.android.package-archive") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android 8.0+ 需要请求安装权限 + if (!context.packageManager.canRequestPackageInstalls()) { + val settingIntent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}") + ) + settingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(settingIntent) + Toaster.show("请允许安装未知来源应用") + } catch (e: Exception) { + LogUtils.e(TAG, "启动安装权限设置失败: ${e.message}") + Toaster.show("无法打开安装权限设置") + } + return + } + } + + try { + // 注册安装完成监听器(仅通过广播监听安装完成) + registerInstallReceiver() + + // 启动安装界面 + context.startActivity(intent) + LogUtils.d(TAG, "已启动APK安装界面,等待安装完成广播...") + } catch (e: Exception) { + e.printStackTrace() + LogUtils.e(TAG, "启动安装失败: ${e.message}") + Toaster.show("安装失败: ${e.message}") + unregisterInstallReceiver() + } + } + + + /** + * 注册安装完成监听器 + */ + private fun registerInstallReceiver() { + unregisterInstallReceiver() // 先取消之前的注册 + + installReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_PACKAGE_REPLACED, Intent.ACTION_PACKAGE_ADDED -> { + val packageName = intent.data?.schemeSpecificPart + if (packageName == context.packageName) { + LogUtils.d(TAG, "检测到应用已安装/更新") + + // 检查是否正在下载或显示更新对话框(如果用户取消了更新,不应该处理) + if (!_isDownloading.value && !_showUpdateDialog.value) { + LogUtils.w(TAG, "检测到安装完成,但更新对话框已关闭,可能是用户取消了更新,不执行任何操作") + unregisterInstallReceiver() + return@onReceive + } + + // 取消注册监听器 + unregisterInstallReceiver() + + // 注意:Android 系统在应用安装/更新完成后会自动重启应用 + // 我们不需要手动调用 restartApp(),避免重复启动 + // 如果系统没有自动重启(某些特殊情况),应用会在下次启动时使用新版本 + LogUtils.d(TAG, "应用安装完成,系统将自动重启应用,无需手动重启") + + // 重置下载状态,确保下次检查更新时是全新状态 + resetDownloadState() + } + } + } + } + } + + val filter = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_ADDED) + addDataScheme("package") + } + + try { + context.registerReceiver(installReceiver, filter) + LogUtils.d(TAG, "已注册安装完成监听器") + } catch (e: Exception) { + LogUtils.e(TAG, "注册安装监听器失败: ${e.message}") + } + } + + /** + * 取消注册安装完成监听器 + */ + private fun unregisterInstallReceiver() { + installReceiver?.let { + try { + context.unregisterReceiver(it) + LogUtils.d(TAG, "已取消注册安装完成监听器") + } catch (e: Exception) { + LogUtils.e(TAG, "取消注册安装监听器失败: ${e.message}") + } + installReceiver = null + } + } + + // 安装监听器 + private var installReceiver: BroadcastReceiver? = null + + /** + * 关闭更新对话框 + * 如果正在下载,会取消下载并重置状态 + */ + fun dismissUpdateDialog() { + LogUtils.d(TAG, "关闭更新对话框") + + // 如果正在下载,取消下载 + if (_isDownloading.value) { + currentDownloadJob?.cancel() + // 清理不完整的下载文件 + currentDownloadFile?.takeIf { it.exists() }?.let { file -> + try { + val deleted = file.delete() + LogUtils.d(TAG, "删除不完整的下载文件: ${file.absolutePath}, 成功: $deleted") + } catch (e: Exception) { + LogUtils.e(TAG, "删除不完整的下载文件失败: ${e.message}") + } + } + } + + // 清理安装相关的资源(防止监听器继续运行) + unregisterInstallReceiver() + + // 关闭对话框 + _showUpdateDialog.value = false + + // 重置下载状态,确保下次检查更新时是全新状态 + resetDownloadState() + + LogUtils.d(TAG, "更新对话框已关闭,所有资源已清理") + } + + /** + * 重置下载状态 + */ + private fun resetDownloadState() { + _isDownloading.value = false + _downloadProgress.value = 0 + currentDownloadFile = null + currentDownloadJob = null + + // 确保清理所有安装相关的资源 + unregisterInstallReceiver() + + LogUtils.d(TAG, "下载状态已重置,所有资源已清理") + } + + /** + * 是否为强制更新 + */ + fun isForceUpdate(): Boolean { + return _latestVersion.value?.is_force_update == 1 + } +} diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudDatabase.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudDatabase.kt new file mode 100644 index 0000000..593017a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudDatabase.kt @@ -0,0 +1,20 @@ +package com.yzx.kiosk.utils.cloud + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.yzx.kiosk.network.model.entity.DownloadTaskEntity +import com.yzx.kiosk.network.model.entity.OssFileTaskEntity + +@Database( + entities = [OssFileTaskEntity::class, DownloadTaskEntity::class], + version = 3, + exportSchema = true +) +@TypeConverters(UploadConverters::class) +abstract class CloudDatabase : RoomDatabase() { + abstract fun ossFileTaskDao(): OssFileTaskDao + + // 新增 + abstract fun downloadTaskDao(): DownloadTaskDao +} diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudDatabaseModule.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudDatabaseModule.kt new file mode 100644 index 0000000..a2041cb --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudDatabaseModule.kt @@ -0,0 +1,65 @@ +package com.yzx.kiosk.utils.cloud + +import android.content.Context +import androidx.room.Room +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + + +@Module +@InstallIn(SingletonComponent::class) +class CloudDatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): CloudDatabase = + Room.databaseBuilder(context, CloudDatabase::class.java, "cloud_storage.db") + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) // 👈 新增 2_3 + .build() + + @Provides + @Singleton + fun provideOssFileTaskDao(db: CloudDatabase): OssFileTaskDao = db.ossFileTaskDao() + + @Provides + @Singleton + fun provideDownloadTaskDao(db: CloudDatabase): DownloadTaskDao = db.downloadTaskDao() +} + + +private val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + // 给老表加列,默认值为空串,且 NOT NULL,兼容已有数据 + db.execSQL( + "ALTER TABLE OssFileTask ADD COLUMN objectKey TEXT NOT NULL DEFAULT ''" + ) + } +} + +private val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS DownloadTask( + id TEXT NOT NULL PRIMARY KEY, + fileUrl TEXT NOT NULL, + fileName TEXT NOT NULL, + fileType INTEGER NOT NULL, + targetPath TEXT NOT NULL, + status TEXT NOT NULL, + progress INTEGER NOT NULL, + downloadedSize INTEGER NOT NULL, + totalSize INTEGER NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ) + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudFileUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudFileUtils.kt new file mode 100644 index 0000000..b7f6a6a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudFileUtils.kt @@ -0,0 +1,44 @@ +package com.yzx.kiosk.utils.cloud + +import androidx.annotation.DrawableRes +import com.yzx.kiosk.R + +object CloudFileUtils { + + fun getFileName(url: String): String { + val path = url.substringBeforeLast("?") + return path.substringAfterLast("/", "") + } + + fun getFileType(fileName: String): String { + val dotIndex = fileName.lastIndexOf('.') + return if (dotIndex != -1 && dotIndex < fileName.length - 1) { + fileName.substring(dotIndex + 1).uppercase() + } else { + "" + } + } + + fun getFileTypeName(type: Int): String { + return if (type == 1) { + "视频" + } else if (type == 2) { + "图片" + } else { + "文件夹" + } + } + + @DrawableRes + fun getIconResId(type: Int): Int { + //1视频 2图片 + return if (type == 1) { + R.mipmap.icon_cloud_storage_file_type_video + } else if (type == 2) { + R.mipmap.icon_cloud_storage_file_type_photo + } else { + R.mipmap.icon_cloud_storage_file_type_folder + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudOssUtils.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudOssUtils.kt new file mode 100644 index 0000000..351ca9e --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudOssUtils.kt @@ -0,0 +1,510 @@ +package com.yzx.kiosk.utils.cloud + +import android.content.Context +import android.provider.OpenableColumns +import androidx.core.net.toUri +import com.alibaba.sdk.android.oss.ClientConfiguration +import com.alibaba.sdk.android.oss.ClientException +import com.alibaba.sdk.android.oss.OSSClient +import com.alibaba.sdk.android.oss.ServiceException +import com.alibaba.sdk.android.oss.callback.OSSCompletedCallback +import com.alibaba.sdk.android.oss.callback.OSSProgressCallback +import com.alibaba.sdk.android.oss.common.auth.OSSCredentialProvider +import com.alibaba.sdk.android.oss.common.auth.OSSStsTokenCredentialProvider +import com.alibaba.sdk.android.oss.internal.OSSAsyncTask +import com.alibaba.sdk.android.oss.model.ResumableDownloadRequest +import com.alibaba.sdk.android.oss.model.ResumableDownloadResult +import com.alibaba.sdk.android.oss.model.ResumableUploadRequest +import com.alibaba.sdk.android.oss.model.ResumableUploadResult +import com.yzx.kiosk.network.model.entity.OssFileTaskEntity +import com.yzx.kiosk.network.model.response.AliyunOSSResponse +import com.yzx.kiosk.network.model.response.NetworkResponse +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.Result +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.utils.FileUtils +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +private data class PreparePath( + val localPath: String, // 传给 ResumableUploadRequest 的真实文件路径 + val fileName: String, // 用于 objectKey / 校验扩展名 + val fromContentUri: Boolean, // 是否是由 content:// 暂存的 + val stageFile: File? // 暂存文件(非 content:// 时为 null) +) + +class CloudOssUtils @Inject constructor( + private val networkRepository: NetWorkRepository, + @ApplicationContext private val context: Context, +) { + + private var ossClient: OSSClient? = null + private val maxFileSize = 2048L * 1024 * 1024 // 2048MB + + // 上传用 + private var currentUploadRequest: ResumableUploadRequest? = null + private var currentUploadTask: OSSAsyncTask? = null + private var cancelledByUser = false + + // 下载用 + private var currentDownloadRequest: ResumableDownloadRequest? = null + private var currentDownloadTask: OSSAsyncTask? = null + private var downloadCancelledByUser = false + + companion object { + private val ALLOWED_EXTENSIONS = setOf("mp4", "avi", "png", "jpg", "jpeg","mp3","wav") + } + + suspend fun uploadFileTask( + task: OssFileTaskEntity, + onProgress: (Int, Long) -> Unit, + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + ) { + withContext(Dispatchers.IO) { + // 获取OSS配置 + val ossConfig = getOssConfig() + if (ossConfig == null) { + ToastUtils.show("文件上传失败") + return@withContext + } + val credentialProvider: OSSCredentialProvider = OSSStsTokenCredentialProvider( + ossConfig.credentials.access_key_id, + ossConfig.credentials.access_key_secret, + ossConfig.credentials.security_token + ) + + + val conf = ClientConfiguration() + + conf.connectionTimeout = 15 * 1000 // 连接超时,默认15秒 + conf.socketTimeout = 15 * 1000 // socket超时,默认15秒 + conf.maxConcurrentRequest = 5 // 最大并发请求数,默认5个 + conf.maxErrorRetry = 2 // 失败后最大重试次数,默认2次 + + ossClient = OSSClient(context, ossConfig.endpoint, credentialProvider, conf) + // 设置Region(如果需要) + ossClient?.setRegion(ossConfig.region) + + val ossResponse = AliyunOSSResponse( + endpoint = ossConfig.endpoint, + bucket = ossConfig.bucket, + credentials = ossConfig.credentials, + baseUrl = ossConfig.baseUrl, + region = ossConfig.region, + expire_seconds = ossConfig.expire_seconds, + ) + + uploadFile(task, onProgress, onSuccess, onFailure, context, ossResponse) + } + } + + private fun uploadFile( + task: OssFileTaskEntity, + onProgress: (Int, Long) -> Unit, + onSuccess: (String) -> Unit, + onFailure: (String) -> Unit, + context: Context, + ossResponse: AliyunOSSResponse?, + ) { + if (ossClient == null) { + ToastUtils.show("OSS客户端未初始化") + return + } + // ① 兼容 content:// → 准备真实本地路径 + val prepared = prepareLocalPathForOss(context, task) + val localPath = prepared.localPath + val realFile = File(localPath) + + // ② 大小/格式校验(扩展名从文件名推导,拿不到就用 MIME→扩展名映射) + val size = if (task.fileSize > 0) task.fileSize else realFile.length() + if (size > maxFileSize) { + onFailure("文件大小不能超过2048MB") + return + } + // 扩展名校验 + var ext = FileUtils.getFileExtension(prepared.fileName).lowercase() + if (ext.isBlank()) { + // 简单的 MIME→扩展名兜底(可按需扩展) + val mime = context.contentResolver.getType(task.filePath.toUri()) ?: "" + ext = when { + mime.startsWith("video/") && mime.contains("mp4") -> "mp4" + mime.startsWith("video/") && mime.contains("avi") -> "avi" + mime.startsWith("image/") && mime.contains("jpeg") -> "jpg" + mime.startsWith("image/") && mime.contains("png") -> "png" + mime.startsWith("audio/") && mime.contains("mpeg") -> "mp3" + mime.startsWith("audio/") && mime.contains("mp3") -> "mp3" + mime.startsWith("audio/") && mime.contains("wav") -> "wav" + mime.startsWith("audio/") && mime.contains("wave") -> "wav" + else -> "" + } + } + if (ext !in ALLOWED_EXTENSIONS) { + onFailure("仅支持.mp4,.avi,.png,.jpg,.jpeg,.mp3,.wav格式") + return + } + + // ③ objectKey + val objectKey = task.objectKey + val recordDirectory = File(context.cacheDir, "oss_record/${task.id}") + .apply { mkdirs() } + .absolutePath + + // ⑤ 创建断点续传请求(一定要传“真实本地路径”) + currentUploadRequest = ResumableUploadRequest( + ossResponse?.bucket, + objectKey, + localPath, // ← 关键:不再使用 task.filePath(可能是 content://) + recordDirectory + ).apply { + setDeleteUploadOnCancelling(false) // 暂停希望能续传,这里必须是 false + setProgressCallback { _, currentSize, totalSize -> + val progress = + if (totalSize > 0) ((currentSize * 100 / totalSize).toInt()) else 0 + onProgress(progress, currentSize) + } + } + + // ⑥ 发起上传 + currentUploadTask = ossClient?.asyncResumableUpload( + currentUploadRequest, + object : OSSCompletedCallback { + override fun onSuccess( + req: ResumableUploadRequest, + res: ResumableUploadResult + ) { + val fileUrl = ossResponse?.baseUrl + objectKey + onSuccess(fileUrl) + // 上传成功:可以清理暂存文件节省空间 + if (prepared.fromContentUri) { + runCatching { prepared.stageFile?.delete() } + } + } + + override fun onFailure( + req: ResumableUploadRequest, + ce: ClientException?, + se: ServiceException? + ) { + val errorMsg = when { + cancelledByUser -> { + cancelledByUser = false + "__CANCELLED__" + } + + ce != null -> "客户端错误: ${ce.message}" + se != null -> "服务端错误: ${se.message}" + else -> "未知错误" + } + onFailure(errorMsg) + } + } + ) + + LogUtils.d("uploadFile: 上传任务已启动,path=$localPath, task=$currentUploadTask") + } + + private suspend fun getOssConfig(): AliyunOSSResponse? { + return try { + val result = networkRepository.aliyunOSSBucket("facebox").asResult() + var finalResult: Result>? = null + + result.collect { res -> + if (res is Result.Success || res is Result.Error) { + finalResult = res + } + } + + when (val fResult = finalResult) { + is Result.Success -> { + val networkResponse = fResult.data as? NetworkResponse + if (networkResponse != null && networkResponse.isSucceeded) { + networkResponse.data + } else { + null + } + } + + else -> null + } + } catch (e: Exception) { + LogUtils.e("CloudOssUtils", "获取OSS配置异常: ${e.message}") + null + } + } + + // 取消当前上传任务 + fun cancelUpload() { + cancelledByUser = true + // 1. 取消当前上传任务(终止SDK内部线程) + currentUploadTask?.cancel() // 调用cancel()会停止上传线程,并保存断点到checkpointFile + // 2. 清空任务引用(避免内存泄漏) + currentUploadTask = null + currentUploadRequest = null // 之后再清空引用 + + } + + + /** + * 清理过期的断点记录文件 + * @param context 上下文 + * @param expireDays 过期天数,默认7天 + */ + fun cleanExpiredCheckpointFiles(context: Context, expireDays: Int = 7) { + val recordDir = File(context.cacheDir, "oss_record") + if (!recordDir.exists()) { + return + } + + val expireTime = System.currentTimeMillis() - expireDays * 24 * 3600 * 1000L + + recordDir.listFiles()?.forEach { file -> + if (file.isFile && file.lastModified() < expireTime) { + file.delete() + LogUtils.d("Deleted expired checkpoint file: ${file.name}") + } + } + } + + /** + * 清理指定任务的断点文件 + * @param context 上下文 + * @param taskId 任务ID + */ + fun cleanCheckpointFileForTask(context: Context, taskId: String) { + val dir = File(context.cacheDir, "oss_record/$taskId") + if (dir.exists()) { + dir.deleteRecursively() + LogUtils.d("Deleted checkpoint dir for task $taskId: ${dir.absolutePath}") + } + } + + private fun prepareLocalPathForOss(context: Context, task: OssFileTaskEntity): PreparePath { + val uri = task.filePath.toUri() + + // file:// 或 纯路径:直接用 + if (uri.scheme.isNullOrEmpty() || uri.scheme == "file") { + val f = if (!uri.scheme.isNullOrEmpty()) File(uri.path ?: task.filePath) + else File(task.filePath) + require(f.exists()) { "文件不存在: ${task.filePath}" } + return PreparePath( + localPath = f.absolutePath, + fileName = task.fileName.ifBlank { f.name }, + fromContentUri = false, + stageFile = null + ) + } + + // content:// → 拷贝到本地缓存(如已存在且大小匹配则复用) + if (uri.scheme == "content") { + val resolver = context.contentResolver + // 1) 决定显示名(用于临时文件名和后续 objectKey) + var displayName = task.fileName + if (displayName.isBlank()) { + resolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME), + null, null, null + )?.use { c -> + if (c.moveToFirst()) { + val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && !c.isNull(idx)) { + displayName = c.getString(idx) + } + } + } + if (displayName.isBlank()) displayName = "unknown_${System.currentTimeMillis()}" + } + + // 2) 目标暂存文件(与 task.id 绑定,便于断点续传/复用) + val stageDir = File(context.cacheDir, "oss_stage").apply { mkdirs() } + val stageFile = File(stageDir, "${task.id}_$displayName") + + // 3) 如果不存在或大小不一致 → 流式拷贝(避免占内存) + if (!stageFile.exists() || (task.fileSize > 0 && stageFile.length() != task.fileSize)) { + resolver.openInputStream(uri)?.use { input -> + stageFile.outputStream().use { output -> + val buf = ByteArray(1024 * 1024) // 1MB buffer + var read: Int + while (true) { + read = input.read(buf) + if (read <= 0) break + output.write(buf, 0, read) + } + output.flush() + } + } ?: error("无法打开 Uri 输入流:$uri") + } + + require(stageFile.exists()) { "暂存失败:$stageFile" } + + return PreparePath( + localPath = stageFile.absolutePath, + fileName = displayName, + fromContentUri = true, + stageFile = stageFile + ) + } + + error("不支持的 URI: ${task.filePath}") + } + + /** + * 断点续传下载 + * @param fileUrl OSS 上完整 URL(例如 https://xxx.aliyuncs.com/cloud_driver/xxx.mp4) + * @param targetPath 本地保存完整路径(如 /storage/emulated/0/Android/data/.../files/xxx.mp4) + */ + suspend fun downloadFileTask( + fileUrl: String, + targetPath: String, + onProgress: (percent: Int, currentSize: Long, totalSize: Long) -> Unit, + onSuccess: () -> Unit, + onFailure: (String) -> Unit, + ) { + withContext(Dispatchers.IO) { + // 1. 获取 OSS 配置 + val ossConfig = getOssConfig() + if (ossConfig == null) { + onFailure("文件下载失败") + return@withContext + } + + // 2. 初始化 OSSClient(如果还没初始化) + if (ossClient == null) { + val credentialProvider: OSSCredentialProvider = OSSStsTokenCredentialProvider( + ossConfig.credentials.access_key_id, + ossConfig.credentials.access_key_secret, + ossConfig.credentials.security_token + ) + + val conf = ClientConfiguration().apply { + connectionTimeout = 15 * 1000 // 连接超时,默认15秒 + socketTimeout = 15 * 1000 // socket超时,默认15秒 + maxConcurrentRequest = 5 // 最大并发请求数,默认5个 + maxErrorRetry = 2 // 失败后最大重试次数,默认2次 + } + + ossClient = OSSClient(context, ossConfig.endpoint, credentialProvider, conf).apply { + // 设置 Region(如果需要) + setRegion(ossConfig.region) + } + } + + val client = ossClient + if (client == null) { + onFailure("OSS客户端未初始化") + return@withContext + } + + // 3. 从 fileUrl 反推 objectKey + // baseUrl 一般类似 https://xxx.oss-xx.aliyuncs.com/ + val baseUrl = ossConfig.baseUrl.trimEnd('/') + val cleanUrl = fileUrl.trim() + if (!cleanUrl.startsWith(baseUrl)) { + onFailure("文件地址与OSS配置不匹配") + return@withContext + } + // 去掉前缀后的就是 objectKey(再去掉开头的 /) + val objectKey = cleanUrl.removePrefix(baseUrl).trimStart('/') + + // 4. 断点记录文件目录,放到 app cache 目录即可 + val checkpointDir = File(context.cacheDir, "oss_download_record").apply { mkdirs() } + val checkpointPath = checkpointDir.absolutePath + + // 5. 构造 ResumableDownloadRequest + // 对应官方示例: + // ResumableDownloadRequest(bucketName, objectKey, localFile) + currentDownloadRequest = ResumableDownloadRequest( + ossConfig.bucket, + objectKey, + targetPath + ).apply { + // 设置分片大小,默认256KB,这里按官方示例写上 + setPartSize(256 * 1024L) + // 开启断点续传 + setEnableCheckPoint(true) + // 设置断点记录文件所在目录(SDK 会在该目录下自动生成 .cp 文件) + setCheckPointFilePath(checkpointPath) + // 进度回调 + progressListener = + OSSProgressCallback { _, currentSize, totalSize -> + val percent = + if (totalSize > 0) ((currentSize * 100 / totalSize).toInt()) else 0 + onProgress(percent, currentSize, totalSize) + } + } + + downloadCancelledByUser = false + + // 6. 发起异步断点续传下载 + currentDownloadTask = client.asyncResumableDownload( + currentDownloadRequest, + object : + OSSCompletedCallback { + override fun onSuccess( + request: ResumableDownloadRequest, + result: ResumableDownloadResult + ) { + LogUtils.d("CloudOssUtils", "downloadFileTask success, local=$targetPath") + onSuccess() + } + + override fun onFailure( + request: ResumableDownloadRequest, + clientException: ClientException?, + serviceException: ServiceException? + ) { + val msg = when { + downloadCancelledByUser -> { + downloadCancelledByUser = false + "__CANCELLED__" + } + + clientException != null -> { + clientException.printStackTrace() + "客户端错误: ${clientException.message}" + } + + serviceException != null -> { + LogUtils.e( + "CloudOssUtils", + "ErrorCode=${serviceException.errorCode}" + ) + LogUtils.e( + "CloudOssUtils", + "RequestId=${serviceException.requestId}" + ) + LogUtils.e("CloudOssUtils", "HostId=${serviceException.hostId}") + LogUtils.e( + "CloudOssUtils", + "RawMessage=${serviceException.rawMessage}" + ) + "服务端错误: ${serviceException.errorCode}" + } + + else -> "未知错误" + } + onFailure(msg) + } + } + ) + + LogUtils.d( + "CloudOssUtils", + "downloadFileTask: 下载任务已启动, objectKey=$objectKey, local=$targetPath" + ) + } + } + + fun cancelDownload() { + downloadCancelledByUser = true + currentDownloadTask?.cancel() + currentDownloadTask = null + currentDownloadRequest = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadRepository.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadRepository.kt new file mode 100644 index 0000000..d387c90 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadRepository.kt @@ -0,0 +1,174 @@ +package com.yzx.kiosk.utils.cloud + +import android.content.Context +import com.yzx.kiosk.network.model.entity.DownloadStatus +import com.yzx.kiosk.network.model.entity.DownloadTaskEntity +import com.yzx.kiosk.utils.FileUtils.saveFileToGallery +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DownloadRepository @Inject constructor( + private val dao: DownloadTaskDao, + private val oss: CloudOssUtils, + @ApplicationContext private val context: Context, +) { + private val runningJobs = mutableMapOf() + + fun observeTasks(): Flow> = dao.observeAll() + + /** 添加下载任务(直接开始下载) */ + suspend fun addTask(fileUrl: String, fileName: String, fileType: Int) { + val id = UUID.randomUUID().toString().replace("-", "") + + // 简单定义本地保存目录:/Android/data/…/files/Download/cloud + val dir = File(context.getExternalFilesDir("Download"), "cloud").apply { mkdirs() } + val targetPath = File(dir, fileName).absolutePath + + val seed = DownloadTaskEntity( + id = id, + fileUrl = fileUrl, + fileName = fileName, + fileType = fileType, + targetPath = targetPath, + status = DownloadStatus.PENDING, + progress = 0, + downloadedSize = 0, + totalSize = 0, + ) + dao.upsert(seed) + start(id) + } + + /** 开始/继续下载 */ + suspend fun start(taskId: String) { + if (runningJobs[taskId]?.isActive == true) return + val task = dao.getById(taskId) ?: return + + dao.upsert( + task.copy( + status = DownloadStatus.DOWNLOADING, + updatedAt = System.currentTimeMillis() + ) + ) + + val flow = callbackFlow { + oss.downloadFileTask( + fileUrl = task.fileUrl, + targetPath = task.targetPath, + onProgress = { percent, downloaded, total -> + trySend(DownloadEvent.Progress(percent, downloaded, total)) + }, + onSuccess = { + trySend(DownloadEvent.Success) + close() + }, + onFailure = { msg -> + trySend(DownloadEvent.Failure(msg)) + close() + } + ) + awaitClose { + // 暂停/取消时由外面调用 oss.cancelDownload() + } + } + + val job = CoroutineScope(Dispatchers.IO).launch { + flow.collect { ev -> + when (ev) { + + is DownloadEvent.Progress -> { + dao.upsert( + task.copy( + status = DownloadStatus.DOWNLOADING, + progress = ev.percent.coerceIn(0, 100), + downloadedSize = ev.downloaded, + totalSize = ev.total, + updatedAt = System.currentTimeMillis() + ) + ) + } + + is DownloadEvent.Success -> { + val newTask = task.copy( + status = DownloadStatus.COMPLETED, + progress = 100, + updatedAt = System.currentTimeMillis() + ) + dao.upsert(newTask) + + // 如果是图片或视频,塞进相册 + val isVideo = newTask.fileType == 1 // 你自己定义:1 视频 2 图片 + val mimeType = if (isVideo) "video/mp4" else "image/jpeg" + + saveFileToGallery( + context = context, + filePath = newTask.targetPath, + fileName = newTask.fileName, + mimeType = mimeType, + isVideo = isVideo + ) + + runningJobs.remove(taskId) + } + + is DownloadEvent.Failure -> { + val newStatus = + if (ev.message == "__CANCELLED__") DownloadStatus.PAUSED + else DownloadStatus.FAILED + + dao.upsert( + task.copy( + status = newStatus, + updatedAt = System.currentTimeMillis() + ) + ) + + runningJobs.remove(taskId) + } + } + } + + } + runningJobs[taskId] = job + } + + suspend fun pause(taskId: String) { + runningJobs[taskId]?.cancel() + runningJobs.remove(taskId) + oss.cancelDownload() + dao.getById(taskId)?.let { + dao.upsert( + it.copy( + status = DownloadStatus.PAUSED, + updatedAt = System.currentTimeMillis() + ) + ) + } + } + + suspend fun resume(taskId: String) = start(taskId) + + suspend fun cancel(taskId: String) { + runningJobs[taskId]?.cancel() + runningJobs.remove(taskId) + oss.cancelDownload() + dao.delete(taskId) + } +} + +sealed class DownloadEvent { + data class Progress(val percent: Int, val downloaded: Long, val total: Long) : DownloadEvent() + data class Failure(val message: String) : DownloadEvent() + data object Success : DownloadEvent() +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadTaskDao.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadTaskDao.kt new file mode 100644 index 0000000..5a1ae1f --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadTaskDao.kt @@ -0,0 +1,28 @@ +package com.yzx.kiosk.utils.cloud + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.yzx.kiosk.network.model.entity.DownloadTaskEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface DownloadTaskDao { + + @Query("SELECT * FROM DownloadTask ORDER BY createdAt DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM DownloadTask WHERE id = :id") + suspend fun getById(id: String): DownloadTaskEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(task: DownloadTaskEntity) + + @Update + suspend fun update(task: DownloadTaskEntity) + + @Query("DELETE FROM DownloadTask WHERE id = :id") + suspend fun delete(id: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/OssFileTaskDao.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/OssFileTaskDao.kt new file mode 100644 index 0000000..33005a4 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/OssFileTaskDao.kt @@ -0,0 +1,28 @@ +package com.yzx.kiosk.utils.cloud + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.yzx.kiosk.network.model.entity.OssFileTaskEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface OssFileTaskDao { + + @Query("SELECT * FROM OssFileTask ORDER BY createdAt DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM OssFileTask WHERE id = :id") + suspend fun getById(id: String): OssFileTaskEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(task: OssFileTaskEntity) + + @Update + suspend fun update(task: OssFileTaskEntity) + + @Query("DELETE FROM OssFileTask WHERE id = :id") + suspend fun delete(id: String) +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadConverters.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadConverters.kt new file mode 100644 index 0000000..797f272 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadConverters.kt @@ -0,0 +1,14 @@ +package com.yzx.kiosk.utils.cloud + +import androidx.room.TypeConverter +import com.yzx.kiosk.network.enums.UploadStatus + +class UploadConverters { + + @TypeConverter + fun fromStatus(status: UploadStatus?): String? = status?.name + + @TypeConverter + fun toStatus(name: String?): UploadStatus? = + name?.let { runCatching { UploadStatus.valueOf(it) }.getOrNull() } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadNotifier.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadNotifier.kt new file mode 100644 index 0000000..e6af5f0 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadNotifier.kt @@ -0,0 +1,23 @@ +package com.yzx.kiosk.utils.cloud + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +data class UploadCompletedEvent( + val taskId: String, + val parentFolderId: Int, + val fileName: String, + val fileUrl: String +) + +@Singleton +class UploadNotifier @Inject constructor() { + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events = _events.asSharedFlow() + + fun emitCompleted(e: UploadCompletedEvent) { + _events.tryEmit(e) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadRepository.kt b/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadRepository.kt new file mode 100644 index 0000000..e4b3423 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadRepository.kt @@ -0,0 +1,183 @@ +package com.yzx.kiosk.utils.cloud + +import android.content.Context +import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.enums.UploadStatus +import com.yzx.kiosk.network.model.entity.OssFileTaskEntity +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.utils.ToastUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UploadRepository @Inject constructor( + private val dao: OssFileTaskDao, + private val oss: CloudOssUtils, // 你的工具类 + @ApplicationContext private val context: Context, + private val notifier: UploadNotifier, + private val repository: NetWorkRepository, + private val appStore: AppStoreDataSource, +) { + // 当前运行中的任务(一个任务一个协程) + private val runningJobs = mutableMapOf() + + fun observeTasks(): Flow> = dao.observeAll() + + /** 新增任务:保存后立即开传(你要求“添加任务即开始上传”) */ + suspend fun addTask(task: OssFileTaskEntity) { + val objectKey = buildObjectKey(appStore.getUserId(), task.fileName) + val seed = task.copy( + objectKey = objectKey, // ✅ 填上稳定 key + status = UploadStatus.PENDING, + progress = 0, + uploadedSize = 0, + updatedAt = System.currentTimeMillis() + ) + dao.upsert(seed) + start(task.id) + ToastUtils.show("已添加上传任务") + } + + /** 开始 / 继续(断点续传自动由 CloudOssUtils 完成) */ + suspend fun start(taskId: String) { + if (runningJobs[taskId]?.isActive == true) return + val task = dao.getById(taskId) ?: return + + dao.upsert( + task.copy( + status = UploadStatus.UPLOADING, + updatedAt = System.currentTimeMillis() + ) + ) + + // —— 用 callbackFlow 把 CloudOssUtils 的回调桥接成 Flow —— // + val flow = callbackFlow { + oss.uploadFileTask( + task = task, + onProgress = { percent, uploaded -> + trySend(UploadEvent.Progress(percent, uploaded)) + }, + onSuccess = { url -> + trySend(UploadEvent.Success(url)) + close() + }, + onFailure = { msg -> + trySend(UploadEvent.Failure(msg)) + close() + } + ) + awaitClose { + // 这里无需额外处理;暂停/取消会由外部调用 oss.cancelUpload() + } + } + + val job = CoroutineScope(Dispatchers.IO).launch { + flow.collect { ev -> + when (ev) { + is UploadEvent.Progress -> { + dao.upsert( + task.copy( + status = UploadStatus.UPLOADING, + progress = ev.percent.coerceIn(0, 100), + uploadedSize = ev.uploaded, + updatedAt = System.currentTimeMillis() + ) + ) + } + + is UploadEvent.Success -> { + val updated = task.copy( + status = UploadStatus.COMPLETED, + progress = 100, + fileUrl = ev.url, + updatedAt = System.currentTimeMillis() + ) + dao.upsert(updated) + + runningJobs.remove(taskId) + + uploadFileToCloud(updated) + } + + is UploadEvent.Failure -> { + val newStatus = + if (ev.message == "__CANCELLED__") UploadStatus.PAUSED else UploadStatus.FAILED + dao.upsert( + task.copy( + status = newStatus, + updatedAt = System.currentTimeMillis() + ) + ) + runningJobs.remove(taskId) + } + } + } + } + runningJobs[taskId] = job + } + + /** 暂停:取消当前上传线程(SDK 会保存 checkpoint),状态置为 PAUSED */ + suspend fun pause(taskId: String) { + runningJobs[taskId]?.cancel() + runningJobs.remove(taskId) + oss.cancelUpload() // 你已有的方法:会触发 onFailure("__CANCELLED__") + dao.getById(taskId)?.let { + dao.upsert( + it.copy( + status = UploadStatus.PAUSED, + updatedAt = System.currentTimeMillis() + ) + ) + } + } + + /** 继续:直接 start(会从 recordDirectory 断点续传) */ + suspend fun resume(taskId: String) = start(taskId) + + /** 取消:停任务 + 清 checkpoint + 置 CANCELLED(是否删除记录看你的业务) */ + suspend fun cancel(taskId: String) { + // 停止上传任务 + runningJobs[taskId]?.cancel() + runningJobs.remove(taskId) + + // 取消 SDK 上传 + oss.cancelUpload() + + // 删除 checkpoint 断点文件(如果有) + oss.cleanCheckpointFileForTask(context, taskId = taskId) + + // 删除数据库记录 → 列表中该项会立即消失 + dao.delete(taskId) + } + + private suspend fun uploadFileToCloud(updated: OssFileTaskEntity) { + + } + + private fun buildObjectKey( + userId: String, + fileName: String, + now: LocalDate = LocalDate.now() + ): String { + val month = now.format(DateTimeFormatter.ofPattern("MM")) + val root = if (BuildConfig.DEBUG) "cloud_driver_test" else "cloud_driver" + return "$root/$userId/${now.year}/$month/$fileName" + } +} + +sealed class UploadEvent { + data class Progress(val percent: Int, val uploaded: Long) : UploadEvent() + data class Success(val url: String) : UploadEvent() + data class Failure(val message: String) : UploadEvent() +} diff --git a/app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt b/app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt new file mode 100644 index 0000000..926e84a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt @@ -0,0 +1,142 @@ +package com.yzx.kiosk.websocket + +import com.yzx.kiosk.websocket.model.PrivacyPrintMessage +import com.yzx.kiosk.utils.LogUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 打印队列管理器 + * 管理隐私取片打印任务队列 + */ +@Singleton +class PrintQueueManager @Inject constructor() { + companion object { + private const val TAG = "PrintQueueManager" + } + + // 打印队列 + private val _printQueue = mutableListOf() + + // 当前正在处理的打印任务 + private val _currentPrintTask = MutableStateFlow(null) + val currentPrintTask: StateFlow = _currentPrintTask.asStateFlow() + + /** + * 添加或更新打印任务 + * @param message 隐私取片消息 + * @return true 如果添加了新任务,false 如果更新了现有任务 + */ + fun addOrUpdateTask(message: PrivacyPrintMessage): Boolean { + synchronized(_printQueue) { + val existingIndex = _printQueue.indexOfFirst { + it.orderNumber == message.orderNumber + } + + if (existingIndex >= 0) { + // 队列中已存在此订单 + val existingTask = _printQueue[existingIndex] + LogUtils.d(TAG, "订单 ${message.orderNumber} 已存在于队列中,当前状态: ${existingTask.printStatus}") + + when (existingTask.printStatus) { + PrivacyPrintMessage.STATUS_NOT_STARTED -> { + // 状态为未开始,更新 image_id + LogUtils.d(TAG, "更新订单 ${message.orderNumber} 的图片列表") + existingTask.imageId.clear() + existingTask.imageId.addAll(message.imageId) + return false + } + PrivacyPrintMessage.STATUS_PRINTING -> { + // 状态为打印中,不做处理 + LogUtils.d(TAG, "订单 ${message.orderNumber} 正在打印中,忽略新消息") + return false + } + PrivacyPrintMessage.STATUS_COMPLETED -> { + // 状态为打印完成,移除旧任务,添加新任务 + LogUtils.d(TAG, "订单 ${message.orderNumber} 已完成,移除旧任务并添加新任务") + _printQueue.removeAt(existingIndex) + _printQueue.add(message) + return true + } + else -> { + LogUtils.i(TAG, "未知的打印状态: ${existingTask.printStatus}") + return false + } + } + } else { + // 队列中不存在此订单,添加新任务 + LogUtils.d(TAG, "添加新打印任务: 订单号=${message.orderNumber}, 手机尾号=${message.userPhoneSuffix}, 图片数量=${message.imageId.size}") + _printQueue.add(message) + return true + } + } + } + + /** + * 获取下一个待处理的打印任务 + */ + fun getNextTask(): PrivacyPrintMessage? { + synchronized(_printQueue) { + val task = _printQueue.firstOrNull { + it.printStatus == PrivacyPrintMessage.STATUS_NOT_STARTED + } + if (task != null) { + task.printStatus = PrivacyPrintMessage.STATUS_PRINTING + _currentPrintTask.value = task + LogUtils.d(TAG, "获取下一个打印任务: 订单号=${task.orderNumber}, 手机尾号=${task.userPhoneSuffix}") + } else { + _currentPrintTask.value = null + LogUtils.d(TAG, "队列中没有待处理的打印任务") + } + return task + } + } + + /** + * 完成当前打印任务 + */ + fun completeCurrentTask() { + synchronized(_printQueue) { + val currentTask = _currentPrintTask.value + if (currentTask != null) { + currentTask.printStatus = PrivacyPrintMessage.STATUS_COMPLETED + LogUtils.d(TAG, "完成打印任务: 订单号=${currentTask.orderNumber}") + + // 从队列中移除已完成的任务 + _printQueue.remove(currentTask) + _currentPrintTask.value = null + + LogUtils.d(TAG, "当前队列剩余任务数: ${_printQueue.size}") + } + } + } + + /** + * 获取队列大小 + */ + fun getQueueSize(): Int { + synchronized(_printQueue) { + return _printQueue.size + } + } + + /** + * 检查队列是否为空 + */ + fun isEmpty(): Boolean { + synchronized(_printQueue) { + return _printQueue.isEmpty() + } + } + + /** + * 获取当前正在处理的任务 + */ + fun getCurrentTask(): PrivacyPrintMessage? { + return _currentPrintTask.value + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt b/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt new file mode 100644 index 0000000..1dd9938 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt @@ -0,0 +1,673 @@ +package com.yzx.kiosk.websocket + +import android.graphics.Bitmap +import coil.Coil +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Size +import com.google.gson.Gson +import com.yzx.kiosk.App +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.model.request.PrintCompleteRequest +import com.yzx.kiosk.network.model.request.PrintNotifyRequest +import com.yzx.kiosk.network.model.response.BatchQueryResponse +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.network.result.ResultHandler +import com.yzx.kiosk.network.result.asResult +import com.yzx.kiosk.priter.PrintRecord +import com.yzx.kiosk.priter.PrinterService +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import com.yzx.kiosk.websocket.model.PrivacyPrintMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import dagger.Lazy +import kotlin.coroutines.resume + +/** + * 隐私取片打印服务 + * 处理隐私取片消息的打印逻辑 + */ +@Singleton +class PrivacyPrintService @Inject constructor( + private val printQueueManager: PrintQueueManager, + private val printerService: PrinterService, + private val networkRepository: NetWorkRepository, + private val appStoreDataSource: AppStoreDataSource, + private val printStatusManager: com.yzx.kiosk.priter.PrintStatusManager, + private val appState: dagger.Lazy +) { + companion object { + private const val TAG = "PrivacyPrintService" + } + + // 打印状态通知 + private val _printNotification = MutableStateFlow(null) + val printNotification: StateFlow = _printNotification.asStateFlow() + + private var isProcessing = false + private val gson = Gson() + private val okHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + + // 缓存 AppState 实例,避免频繁调用 Lazy.get() + private val appStateInstance: com.yzx.kiosk.datastore.AppState by lazy { + appState.get() + } + + /** + * 处理下一个打印任务 + */ + suspend fun processNextTask() { + if (isProcessing) { + LogUtils.d(TAG, "已有任务正在处理中,跳过") + return + } + + val task = printQueueManager.getNextTask() + if (task == null) { + LogUtils.d(TAG, "队列中没有待处理的任务") + updateNotification(null) + return + } + + isProcessing = true + + try { + LogUtils.d(TAG, "开始处理打印任务: 订单号=${task.orderNumber}, 手机尾号=${task.userPhoneSuffix}") + + // 获取设备 SN + val deviceSn = appStoreDataSource.getBindBoxSn() + if (deviceSn.isEmpty()) { + LogUtils.e(TAG, "设备 SN 为空,无法查询图片") + updateNotification("手机尾号${task.userPhoneSuffix}用户,打印失败。打印时,不影响当前屏幕操作") + printQueueManager.completeCurrentTask() + isProcessing = false + processNextTask() // 处理下一个任务 + return + } + + // 调用批量查询接口获取图片信息 + val batchQueryResponse = fetchBatchQueryResponse(deviceSn, task.imageId) + if (batchQueryResponse == null || batchQueryResponse.images == null || batchQueryResponse.images.isEmpty()) { + LogUtils.e(TAG, "未获取到图片信息") + ToastUtils.show("获取图片列表失败") + updateNotification("手机尾号${task.userPhoneSuffix}用户,打印失败。打印时,不影响当前屏幕操作") + printQueueManager.completeCurrentTask() + isProcessing = false + processNextTask() // 处理下一个任务 + return + } + // 更新通知:正在打印中 + updateNotification("手机尾号${task.userPhoneSuffix}用户,正在打印中...打印时,不影响当前屏幕操作") + // 显示图片数量 Toast + val imageCount = batchQueryResponse.images.size + ToastUtils.show("获取到 $imageCount 张图片") + LogUtils.d(TAG, "获取到 $imageCount 张图片") + + // 打印图片 + var successCount = 0 + var failCount = 0 + + // 构建 imageId 到 URL 的映射 + val imageIdToUrlMap = mutableMapOf() + batchQueryResponse.images.forEach { imageInfo -> + val url = if (appStoreDataSource.getUseLan()) imageInfo.thumbnailLanUrl else imageInfo.thumbnailOssUrl + if (url != null) { + imageIdToUrlMap[imageInfo.id] = url + } + } + + for ((index, imageId) in task.imageId.withIndex()) { + // 维度1: 检查是否有图片URL + val imageUrl = imageIdToUrlMap[imageId] + if (imageUrl == null) { + LogUtils.e(TAG, "图片 $imageId 的 URL 不存在,跳过打印") + failCount++ + // 保存打印记录 + val record = PrintRecord( + fileName = "图片_$imageId", + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "图片URL不存在" + ) + appStoreDataSource.addPrintRecord(record) + notifyPrintStatus( + captureType = task.captureType, + orderNumber = task.orderNumber, + imageId = imageId, + printStatus = 2 + ) + continue + } + + LogUtils.d(TAG, "开始打印第 ${index + 1}/${task.imageId.size} 张图片 (ID=$imageId): $imageUrl") + + try { + // 维度2: 检查打印机是否在线 + if (printStatusManager.isOffline()) { + LogUtils.e(TAG, "图片 $imageId 打印失败: 打印机离线") + ToastUtils.show("图片 $imageId 打印失败: 打印机离线") + failCount++ + // 保存打印记录 + val fileName = imageUrl.substringAfterLast("/").takeIf { it.isNotEmpty() } ?: "图片_$imageId" + val record = PrintRecord( + fileName = fileName, + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "打印机离线" + ) + appStoreDataSource.addPrintRecord(record) + notifyPrintStatus( + captureType = task.captureType, + orderNumber = task.orderNumber, + imageId = imageId, + printStatus = 2 + ) + continue + } + + // 维度3: 检查图片下载是否成功 + val bitmap = downloadImage(imageUrl) + if (bitmap == null) { + LogUtils.e(TAG, "图片 $imageId 下载失败") + failCount++ + // 保存打印记录 + val fileName = imageUrl.substringAfterLast("/").takeIf { it.isNotEmpty() } ?: "图片_$imageId" + val record = PrintRecord( + fileName = fileName, + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "图片下载失败" + ) + appStoreDataSource.addPrintRecord(record) + notifyPrintStatus( + captureType = task.captureType, + orderNumber = task.orderNumber, + imageId = imageId, + printStatus = 2 + ) + continue + } + + LogUtils.d(TAG, "图片 $imageId 下载成功,尺寸: ${bitmap.width}x${bitmap.height}") + + // 在调用打印前,再次检查打印机是否在线(防止状态在下载图片期间发生变化) + if (printStatusManager.isOffline()) { + LogUtils.e(TAG, "图片 $imageId 打印失败: 打印机离线(打印前检查)") + ToastUtils.show("图片 $imageId 打印失败: 打印机离线(打印前检查)") + failCount++ + // 保存打印记录 + val fileName = imageUrl.substringAfterLast("/").takeIf { it.isNotEmpty() } ?: "图片_$imageId" + val record = PrintRecord( + fileName = fileName, + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "打印机离线(打印前检查)" + ) + appStoreDataSource.addPrintRecord(record) + notifyPrintStatus( + captureType = task.captureType, + orderNumber = task.orderNumber, + imageId = imageId, + printStatus = 2 + ) + continue + } + + // 维度4: 检查打印机回调结果 + val printResult = withContext(Dispatchers.IO) { + suspendCancellableCoroutine> { continuation -> + // 在协程作用域中调用挂起函数 + CoroutineScope(Dispatchers.IO).launch { + printerService.startPrint(bitmap, object : com.yzx.kiosk.priter.PrintCallback { + override fun onPrintResult(isSuccess: Boolean, msg: String) { + LogUtils.d(TAG, "图片 $imageId 打印回调: isSuccess=$isSuccess, msg=$msg") + + // 获取回调时的打印机状态(用于判断是否离线) + val isPrinterOffline = printStatusManager.isOffline() + val printerStatusForCheck = if (isPrinterOffline) { + com.yzx.kiosk.priter.PrintStatusManager.STATUS_OFFLINE + } else { + printStatusManager.getPrintStatus() + } + LogUtils.d(TAG, "图片 $imageId 回调时打印机状态: $printerStatusForCheck (在线=${!isPrinterOffline})") + + // 综合判断:检查回调结果、错误消息、打印机状态 + val finalSuccess = determinePrintSuccess( + imageId = imageId, + callbackSuccess = isSuccess, + callbackMsg = msg, + printerStatus = printerStatusForCheck + ) + + continuation.resume(Pair(finalSuccess, msg)) + } + }) + } + } + } + + val printSuccess = printResult.first + val printMsg = printResult.second + + // 保存打印记录 + val fileName = imageUrl.substringAfterLast("/").takeIf { it.isNotEmpty() } ?: "图片_$imageId" + val record = PrintRecord( + fileName = fileName, + printEndTime = System.currentTimeMillis(), + isSuccess = printSuccess, + message = printMsg + ) + appStoreDataSource.addPrintRecord(record) + LogUtils.d(TAG, "保存打印记录: $record") + + // 根据综合判断结果更新计数 + if (printSuccess) { + successCount++ + LogUtils.d(TAG, "图片 $imageId 打印成功(综合判断)") + ToastUtils.show("图片 $imageId 打印成功") + } else { + failCount++ + LogUtils.e(TAG, "图片 $imageId 打印失败(综合判断): $printMsg") + ToastUtils.show("图片 $imageId 打印失败") + } + + LogUtils.d(TAG, "图片 $imageId 最终打印结果: 成功=$printSuccess, 消息=$printMsg") + + // 调用打印通知接口 + notifyPrintStatus( + captureType = task.captureType, + orderNumber = task.orderNumber, + imageId = imageId, + printStatus = if (printSuccess) 1 else 2 + ) + + // 打印完成后回收 Bitmap,避免内存泄漏 + try { + if (!bitmap.isRecycled) { + bitmap.recycle() + LogUtils.d(TAG, "图片 $imageId Bitmap 已回收") + } + } catch (e: Exception) { + LogUtils.e(TAG, "回收 Bitmap 异常: ${e.message}") + } + } catch (e: Exception) { + failCount++ + LogUtils.e(TAG, "打印图片 $imageId 异常: ${e.message}") + e.printStackTrace() + // 保存打印记录 + val fileName = imageIdToUrlMap[imageId]?.substringAfterLast("/")?.takeIf { it.isNotEmpty() } ?: "图片_$imageId" + val record = PrintRecord( + fileName = fileName, + printEndTime = System.currentTimeMillis(), + isSuccess = false, + message = "打印异常: ${e.message ?: "未知错误"}" + ) + appStoreDataSource.addPrintRecord(record) + notifyPrintStatus( + captureType = task.captureType, + orderNumber = task.orderNumber, + imageId = imageId, + printStatus = 2 + ) + } + } + + // 调用打印完成接口 + notifyPrintComplete( + captureType = task.captureType, + orderNumber = task.orderNumber + ) + + // 更新通知:打印完成 + if (failCount == 0) { + updateNotification("手机尾号${task.userPhoneSuffix}用户,打印完成。打印时,不影响当前屏幕操作") + } else { + updateNotification("手机尾号${task.userPhoneSuffix}用户,打印失败。打印时,不影响当前屏幕操作") + } + + // 完成任务 + printQueueManager.completeCurrentTask() + + LogUtils.d(TAG, "打印任务完成: 成功=$successCount, 失败=$failCount") + + } catch (e: Exception) { + LogUtils.e(TAG, "处理打印任务异常: ${e.message}") + e.printStackTrace() + updateNotification("手机尾号${task.userPhoneSuffix}用户,打印失败。打印时,不影响当前屏幕操作") + printQueueManager.completeCurrentTask() + } finally { + isProcessing = false + + // 处理下一个任务 + processNextTask() + } + } + + /** + * 综合判断打印是否成功 + * 从多个维度判断:回调结果、错误消息、打印机状态 + * 特别注意:即使回调返回成功,如果消息中包含 "Print failed" 等失败关键词,也应判定为失败 + */ + private fun determinePrintSuccess( + imageId: Int, + callbackSuccess: Boolean, + callbackMsg: String, + printerStatus: Int + ): Boolean { + LogUtils.d(TAG, "========== 图片 $imageId 开始综合判断打印结果 ==========") + LogUtils.d(TAG, "callbackSuccess: $callbackSuccess") + LogUtils.d(TAG, "callbackMsg: '$callbackMsg'") + LogUtils.d(TAG, "printerStatus: $printerStatus") + + // 维度1: 优先检查错误消息中是否包含失败关键词 + // 即使 callbackSuccess 为 true,如果消息包含失败关键词,也应判定为失败 + // 这是最重要的检查,因为打印机SDK可能在日志中输出 "Print failed",但回调可能仍然返回成功 + val failureKeywords = listOf( + "失败", + "error", + "failed", + "print failed", + "Print failed", + "PRINT FAILED", + "打印失败", + "出理失败", // 处理失败(可能是错别字) + "获取图片失败" + ) + val msgTrimmed = callbackMsg.trim() + val msgLower = msgTrimmed.lowercase() + + if (msgTrimmed.isNotEmpty()) { + // 检查消息中是否包含任何失败关键词(不区分大小写) + val containsFailureKeyword = failureKeywords.any { keyword -> + msgLower.contains(keyword.lowercase()) + } + + if (containsFailureKeyword) { + LogUtils.e(TAG, ">>> 图片 $imageId 打印失败: 错误消息包含失败关键词 <<<") + LogUtils.e(TAG, "原始消息: '$callbackMsg'") + LogUtils.e(TAG, "匹配的关键词: ${failureKeywords.find { msgLower.contains(it.lowercase()) }}") + LogUtils.d(TAG, "==========================================") + return false + } + } + + // 维度2: 检查打印机状态(优先于回调结果检查,因为即使回调返回成功,如果打印机离线,也应该判定为失败) + if (printerStatus == com.yzx.kiosk.priter.PrintStatusManager.STATUS_OFFLINE) { + LogUtils.e(TAG, ">>> 图片 $imageId 打印失败: 打印机离线 <<<") + LogUtils.e(TAG, "注意: 即使回调返回成功,打印机离线也应判定为失败") + LogUtils.d(TAG, "==========================================") + return false + } + + // 维度3: 检查回调中的成功标志 + if (!callbackSuccess) { + LogUtils.e(TAG, ">>> 图片 $imageId 打印失败: 回调返回失败 <<<") + LogUtils.e(TAG, "错误消息: '$callbackMsg'") + LogUtils.d(TAG, "==========================================") + return false + } + + // 维度4: 如果消息为空但 callbackSuccess 为 true,需要额外验证 + // 某些情况下,打印机SDK可能返回成功但实际打印失败 + if (msgTrimmed.isEmpty() && callbackSuccess) { + // 再次检查打印机是否在线,确保打印机确实在线 + if (printStatusManager.isOffline()) { + LogUtils.e(TAG, ">>> 图片 $imageId 打印失败: 消息为空但打印机离线 <<<") + LogUtils.d(TAG, "==========================================") + return false + } + } + + // 所有检查通过,认为打印成功 + LogUtils.d(TAG, ">>> 图片 $imageId 打印成功: 所有维度检查通过 <<<") + LogUtils.d(TAG, "==========================================") + return true + } + + /** + * 获取批量查询响应 + */ + private suspend fun fetchBatchQueryResponse(deviceSn: String, imageIds: List): BatchQueryResponse? { + return try { + // 构建批量查询 URL + val idsString = imageIds.joinToString(",") + val baseUrl = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl() + val url = "$baseUrl/api/image/batch-query?ids=$idsString" + + LogUtils.d(TAG, "调用批量查询接口: $url") + + // 获取 Face Search Token + val faceSearchToken = appStoreDataSource.getBindBoxApiToken() + val authorization = if (faceSearchToken.isNotEmpty()) { + "Bearer $faceSearchToken" + } else { + "" + } + + LogUtils.d(TAG, "Authorization: $authorization") + + // 使用 OkHttp 直接调用设备 API + val requestBuilder = Request.Builder() + .url(url) + .get() + + // 添加 Authorization 请求头 + if (authorization.isNotEmpty()) { + requestBuilder.addHeader("Authorization", authorization) + } + + val request = requestBuilder.build() + val response = okHttpClient.newCall(request).execute() + + if (response.isSuccessful) { + val responseBody = response.body?.string() + LogUtils.d(TAG, "批量查询响应: $responseBody") + + if (!responseBody.isNullOrEmpty()) { + val batchQueryResponse = gson.fromJson(responseBody, BatchQueryResponse::class.java) + if (batchQueryResponse.success) { + LogUtils.d(TAG, "获取到 ${batchQueryResponse.images?.size ?: 0} 张图片信息") + return batchQueryResponse + } + } + } else { + LogUtils.e(TAG, "批量查询失败: ${response.code} ${response.message}") + } + + null + } catch (e: Exception) { + LogUtils.e(TAG, "获取图片信息失败: ${e.message}") + e.printStackTrace() + null + } + } + + /** + * 获取设备 IP 地址 + */ + private fun getDeviceIp(deviceSn: String): String { + // 从本地存储获取设备 IP,如果没有则使用默认值 + val lanIp = appStoreDataSource.getBindBoxLanIp() + return if (lanIp.isNotEmpty()) { + lanIp + } else { + // 默认使用 localhost + "127.0.0.1" + } + } + + /** + * 下载图片 + * 优化:使用全局 ImageLoader,限制图片最大尺寸,避免内存溢出 + */ + private suspend fun downloadImage(imageUrl: String): Bitmap? { + var bitmap: Bitmap? = null + return try { + LogUtils.d(TAG, "开始下载图片: $imageUrl") + + // 使用全局 ImageLoader,避免重复创建 + val imageLoader = Coil.imageLoader(App.instance) + + // 限制图片最大尺寸为 3000x3000,避免加载过大图片导致内存溢出 + val maxSize = 3000 + val request = ImageRequest.Builder(App.instance) + .data(imageUrl) + .size(Size(maxSize, maxSize)) + .allowHardware(false) + .build() + + val result = imageLoader.execute(request) + val drawable = result.drawable + + if (drawable != null) { + val width = drawable.intrinsicWidth.coerceAtMost(maxSize) + val height = drawable.intrinsicHeight.coerceAtMost(maxSize) + + // 将 Drawable 转换为 Bitmap + bitmap = android.graphics.Bitmap.createBitmap( + width, + height, + android.graphics.Bitmap.Config.ARGB_8888 + ) + val canvas = android.graphics.Canvas(bitmap) + drawable.setBounds(0, 0, width, height) + drawable.draw(canvas) + + LogUtils.d(TAG, "图片下载成功: ${bitmap.width}x${bitmap.height}") + bitmap + } else { + LogUtils.e(TAG, "图片下载失败: drawable 为 null") + null + } + } catch (e: OutOfMemoryError) { + LogUtils.e(TAG, "下载图片内存溢出: ${e.message}") + // 如果内存溢出,尝试回收 bitmap + bitmap?.recycle() + System.gc() + null + } catch (e: Exception) { + LogUtils.e(TAG, "下载图片异常: ${e.message}") + e.printStackTrace() + // 确保异常时也回收 bitmap + bitmap?.recycle() + null + } + } + + /** + * 通知打印状态 + * + * @param printStatus 订单打印结果(与心跳包中的 print_status 含义不同) + * - 1: 打印成功 + * - 2: 打印失败 + * + * 注意:心跳包中的 print_status 表示打印机状态(1-空闲,2-打印中,3-离线) + */ + private suspend fun notifyPrintStatus( + captureType: Int, + orderNumber: String, + imageId: Int, + printStatus: Int + ) { + try { + LogUtils.d(TAG, "通知打印状态(订单打印结果): captureType=$captureType, orderNumber=$orderNumber, imageId=$imageId, printStatus=$printStatus (1=成功, 2=失败)") + + // 如果打印成功,更新打印纸张数(减1) + var remainingPaperNum: Int? = null + if (printStatus == 1) { + val currentPaperNum = appStoreDataSource.getRemainingPaperNum() + val newPaperNum = (currentPaperNum - 1).coerceAtLeast(0) + appStoreDataSource.saveRemainingPaperNum(newPaperNum) + // 同步更新全局状态,以便首页实时更新(使用缓存的实例,避免频繁调用 Lazy.get()) + appStateInstance.updateRemainingPaperNum(newPaperNum) + remainingPaperNum = newPaperNum + LogUtils.d(TAG, "打印成功,更新纸张数: $currentPaperNum -> $newPaperNum") + } + + val request = PrintNotifyRequest( + captureType = captureType, + orderNumber = orderNumber, + imageId = imageId, + printStatus = printStatus, + remainingPaperNum = remainingPaperNum + ) + + ResultHandler.handleResultWithData( + scope = CoroutineScope(Dispatchers.IO), + flow = networkRepository.printNotify(request).asResult(), + showToast = false, + onData = { + LogUtils.d(TAG, "打印状态通知成功") + }, + onError = { msg, _ -> + LogUtils.e(TAG, "打印状态通知失败: $msg") + } + ) + } catch (e: Exception) { + LogUtils.e(TAG, "打印状态通知异常: ${e.message}") + } + } + + /** + * 通知打印完成 + */ + private suspend fun notifyPrintComplete( + captureType: Int, + orderNumber: String + ) { + try { + LogUtils.d(TAG, "通知打印完成: captureType=$captureType, orderNumber=$orderNumber") + + val request = PrintCompleteRequest( + captureType = captureType, + orderNumber = orderNumber + ) + + ResultHandler.handleResultWithData( + scope = CoroutineScope(Dispatchers.IO), + flow = networkRepository.printComplete(request).asResult(), + showToast = false, + onData = { + LogUtils.d(TAG, "打印完成通知成功") + }, + onError = { msg, _ -> + LogUtils.e(TAG, "打印完成通知失败: $msg") + } + ) + } catch (e: Exception) { + LogUtils.e(TAG, "打印完成通知异常: ${e.message}") + } + } + + /** + * 更新打印通知 + */ + private fun updateNotification(message: String?) { + if (message != null) { + _printNotification.value = message + } else { + // 如果队列为空,显示默认消息 + _printNotification.value = "当前无小程序用户操作打印。打印时,不影响当前屏幕操作" + } + } +} + diff --git a/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt b/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt new file mode 100644 index 0000000..40e9b2a --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt @@ -0,0 +1,974 @@ +package com.yzx.kiosk.websocket + +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.yzx.kiosk.BuildConfig +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.ui.poster.ScenicLivePosterController +import com.yzx.kiosk.priter.PrintStatusManager +import com.yzx.kiosk.utils.InactivityManager +import com.yzx.kiosk.utils.LogUtils +import com.yzx.kiosk.utils.ToastUtils +import com.yzx.kiosk.websocket.model.PrivacyPrintMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * WebSocket 服务类 + * 负责 WebSocket 连接、消息发送和接收、心跳包、token 绑定等 + */ +@Singleton +class WebSocketService @Inject constructor( + private val appStoreDataSource: AppStoreDataSource, + private val networkRepository: NetWorkRepository, + private val printStatusManager: PrintStatusManager, + private val printQueueManager: PrintQueueManager, + private val privacyPrintService: PrivacyPrintService, + private val inactivityManager: InactivityManager, + private val scenicLivePosterController: ScenicLivePosterController +) { + companion object { + private const val TAG = "WebSocketService" + private const val HEARTBEAT_INTERVAL = 5000L // 心跳间隔:5秒 + private const val RECONNECT_DELAY = 3000L // 重连延迟:3秒 + private const val MAX_RECONNECT_ATTEMPTS = 5 // 最大重连次数 + private const val HEARTBEAT_TIMEOUT = 10000L // 心跳包响应超时:10秒 + /** Token 绑定成功后延迟发送 type=304(景区直播订阅) */ + private const val SCENIC_LIVE_SUBSCRIBE_DELAY_MS = 5000L + } + + /** 绑定成功后延迟发送 type=304 的任务 */ + private var scenicLiveSubscribeJob: Job? = null + + private var webSocket: WebSocket? = null + private var okHttpClient: OkHttpClient? = null + private var heartbeatJob: Job? = null + private var heartbeatTimeoutJob: Job? = null // 心跳包响应超时检测任务 + private var reconnectJob: Job? = null + private var reconnectAttempts = 0 + private var isConnecting = false + private var isBound = false // token 是否已绑定成功 + private var isWaitingHeartbeatResponse = false // 是否正在等待心跳包响应 + + private val gson = Gson() + + // WebSocket 消息监听器 + private val messageListeners = mutableListOf() + + // 上传照片相关事件 + private val _uploadPhotoEvents = MutableSharedFlow() + val uploadPhotoEvents: SharedFlow = _uploadPhotoEvents.asSharedFlow() + + /** + * 添加消息监听器 + */ + fun addMessageListener(listener: WebSocketMessageListener) { + messageListeners.add(listener) + } + + /** + * 移除消息监听器 + */ + fun removeMessageListener(listener: WebSocketMessageListener) { + messageListeners.remove(listener) + } + + /** + * 初始化 OkHttpClient(用于 WebSocket) + */ + private fun initOkHttpClient() { + if (okHttpClient == null) { + okHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) // 自动 ping + .build() + } + } + + /** + * 启动 WebSocket 连接 + */ + fun start() { + if (isConnecting || webSocket != null) { + LogUtils.d(TAG, "WebSocket 正在连接或已连接,跳过") + return + } + + val socketToken = appStoreDataSource.getSocketToken() + if (socketToken.isEmpty()) { + LogUtils.e(TAG, "Socket Token 为空,无法启动 WebSocket") + return + } + + isConnecting = true + initOkHttpClient() + + val request = Request.Builder() + .url(BuildConfig.WSS) + .build() + + LogUtils.d(TAG, "开始连接 WebSocket: ${BuildConfig.WSS}") + + webSocket = okHttpClient!!.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + LogUtils.d(TAG, "========== WebSocket 连接成功 ==========") + LogUtils.d(TAG, "响应状态码: ${response.code}") + LogUtils.d(TAG, "响应消息: ${response.message}") + isConnecting = false + reconnectAttempts = 0 + + // 连接成功后发送 token 绑定消息 + LogUtils.d(TAG, "准备发送 Token 绑定消息") + sendTokenBindMessage() + + // 启动心跳包(即使未绑定也会启动,但会等待绑定成功后再发送) + // LogUtils.d(TAG, "准备启动心跳包") + startHeartbeat() + // LogUtils.d(TAG, "========================================") + } + + override fun onMessage(webSocket: WebSocket, text: String) { + logWebSocketRx(text) + LogUtils.d(TAG, "处理前绑定状态: $isBound") + + // 解析消息内容并显示长Toast + try { + val jsonObject = gson.fromJson(text, JsonObject::class.java) + val msg = jsonObject.get("msg")?.asString +// if (!msg.isNullOrEmpty()) { +// // 在主线程显示Toast +// CoroutineScope(Dispatchers.Main).launch { +// ToastUtils.showLong(text) +// } +// } + } catch (e: Exception) { + // 如果解析失败,显示原始消息 +// CoroutineScope(Dispatchers.Main).launch { +// ToastUtils.showLong(text) +// } + } + + handleMessage(text) + LogUtils.d(TAG, "处理后绑定状态: $isBound") + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + LogUtils.d(TAG, "WebSocket 正在关闭: code=$code, reason=$reason") + webSocket.close(1000, null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + LogUtils.d(TAG, "WebSocket 已关闭: code=$code, reason=$reason") + scenicLiveSubscribeJob?.cancel() + scenicLiveSubscribeJob = null + this@WebSocketService.webSocket = null + isBound = false + isWaitingHeartbeatResponse = false + stopHeartbeat() + + // 尝试重连 + scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + LogUtils.e(TAG, "WebSocket 连接失败: ${t.message}") + scenicLiveSubscribeJob?.cancel() + scenicLiveSubscribeJob = null + isConnecting = false + this@WebSocketService.webSocket = null + isBound = false + isWaitingHeartbeatResponse = false + stopHeartbeat() + + // 尝试重连 + scheduleReconnect() + } + }) + } + + /** + * 发送 Token 绑定消息 + */ + private fun sendTokenBindMessage() { + val socketToken = appStoreDataSource.getSocketToken() + if (socketToken.isEmpty()) { + LogUtils.e(TAG, "Socket Token 为空,无法发送绑定消息") + return + } + + val bindMessage = JsonObject().apply { + addProperty("type", 1000) + add("params", JsonObject().apply { + addProperty("socket_token", socketToken) + }) + } + + val messageJson = gson.toJson(bindMessage) + val result = sendMessage(messageJson) + if (result) { + LogUtils.d(TAG, "Token 绑定已发出,等待响应(当前 isBound=$isBound)") + } else { + LogUtils.e(TAG, "Token 绑定消息发送失败") + } + } + + /** + * 处理接收到的消息 + */ + private fun handleMessage(text: String) { + try { + val jsonObject = gson.fromJson(text, JsonObject::class.java) + // 处理 code 字段,可能是数字或字符串,统一转换为 String + val codeElement = jsonObject.get("code") + val code = when { + codeElement?.isJsonPrimitive == true -> { + val primitive = codeElement.asJsonPrimitive + if (primitive.isNumber) { + // 如果是数字,转换为字符串 + primitive.asString + } else { + // 如果已经是字符串,直接使用 + primitive.asString + } + } + else -> "0" + } + val msg = jsonObject.get("msg")?.asString ?: "" + + LogUtils.d(TAG, "解析消息 - code: $code (原始类型: ${codeElement?.javaClass?.simpleName}), msg: $msg") + LogUtils.d(TAG, "完整消息内容: $text") + + when (code) { + "100000" -> { + if (tryHandleScenicLiveAction100000(jsonObject)) { + // 景区直播 action 或未知 action(已消费,不走绑定/心跳) + } else { + // code 100000 绑定成功或心跳包响应 + val dataElement = jsonObject.get("data") + val isHeartbeatResponse = msg.equals("ok", ignoreCase = true) && + (dataElement == null || + dataElement.isJsonArray == true || + (dataElement.isJsonObject && dataElement.asJsonObject.get("sn") == null)) + + if (isHeartbeatResponse) { + cancelHeartbeatTimeout() + } else { + LogUtils.d(TAG, "========== Token 绑定成功 ==========") + LogUtils.d(TAG, "响应消息: $msg") + val oldBoundState = isBound + LogUtils.d(TAG, "绑定前状态: $oldBoundState") + + val data = try { + if (jsonObject.has("data") && jsonObject.get("data").isJsonObject) { + jsonObject.getAsJsonObject("data") + } else { + null + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析 data 对象失败: ${e.message}") + null + } + + val sn = data?.get("sn")?.asString + LogUtils.d(TAG, "设备 SN: $sn") + + isBound = true + LogUtils.d(TAG, "绑定后状态: $isBound") + LogUtils.d(TAG, "绑定状态已更新: $oldBoundState -> $isBound") + + messageListeners.forEach { it.onTokenBound(sn) } + + LogUtils.d(TAG, "Token 绑定成功,心跳包现在可以正常发送") + LogUtils.d(TAG, "=====================================") + scheduleScenicLiveSubscribeAfterBind() + } + } + } + "100099" -> { + // Token 无效或已过期 + LogUtils.e(TAG, ">>> Token 无效或已过期 <<<") + LogUtils.e(TAG, "错误消息: $msg") + isBound = false + + // 重新获取 token 并绑定 + refreshTokenAndBind() + } + "1" -> { + // 扫码成功消息 + LogUtils.d(TAG, ">>> 收到扫码成功消息 (code=1) <<<") + try { + val dataElement = jsonObject.get("data") + if (dataElement != null && dataElement.isJsonObject) { + val dataObj = dataElement.asJsonObject + val uuid = dataObj.get("uuid")?.asString + val openId = dataObj.get("open_id")?.asString + LogUtils.d(TAG, "扫码成功 - uuid: $uuid, open_id: $openId") + + // 发送扫码成功事件 + CoroutineScope(Dispatchers.Main).launch { + _uploadPhotoEvents.emit(UploadPhotoEvent.ScanSuccess(uuid, openId)) + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析扫码成功消息失败: ${e.message}") + } + } + "2" -> { + // 收到文件列表消息 + LogUtils.d(TAG, ">>> 收到文件列表消息 (code=2) <<<") + try { + val dataElement = jsonObject.get("data") + if (dataElement != null && dataElement.isJsonObject) { + val dataObj = dataElement.asJsonObject + val uuid = dataObj.get("uuid")?.asString + val fileMapArray = dataObj.get("file_map") + + if (fileMapArray != null && fileMapArray.isJsonArray) { + val fileList = mutableListOf() + fileMapArray.asJsonArray.forEach { element -> + if (element.isJsonObject) { + val fileObj = element.asJsonObject + val id = fileObj.get("id")?.asInt + val originalOssUrl = fileObj.get("original_oss_url")?.asString + val thumbnailOssUrl = fileObj.get("thumbnail_oss_url")?.asString + val thumbnailLanUrl = fileObj.get("thumbnail_lan_url")?.asString + val originalLanUrl = fileObj.get("original_lan_url")?.asString + + if (id != null) { + fileList.add( + com.yzx.kiosk.ui.upload.viewmodel.FileMapData( + id = id, + originalOssUrl = originalOssUrl, + thumbnailOssUrl = thumbnailOssUrl, + thumbnailLanUrl = thumbnailLanUrl, + originalLanUrl = originalLanUrl + ) + ) + } + } + } + + if (fileList.isNotEmpty()) { + LogUtils.d(TAG, "收到文件列表 - uuid: $uuid, 文件数量: ${fileList.size}") + + // 发送文件列表事件 + CoroutineScope(Dispatchers.Main).launch { + _uploadPhotoEvents.emit(UploadPhotoEvent.FileListReceived(fileList)) + } + } else { + LogUtils.d(TAG, "文件列表为空,不处理") + } + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析文件列表消息失败: ${e.message}") + } + } + "5" -> { + // 支付成功消息 + LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<") + try { + val dataElement = jsonObject.get("data") + if (dataElement != null && dataElement.isJsonObject) { + val dataObj = dataElement.asJsonObject + val orderNumber = dataObj.get("order_number")?.asString + val captureType = dataObj.get("capture_type")?.asInt + + val imageIdList = mutableListOf() + if (dataObj.has("image_id")) { + val imageIdArray = dataObj.get("image_id") + if (imageIdArray != null && imageIdArray.isJsonArray) { + imageIdArray.asJsonArray.forEach { element -> + element.asInt.let { imageIdList.add(it) } + } + } + } + + var fileMap = "" + if (dataObj.has("file_map")){ + fileMap = dataObj.get("file_map").toString() + } + + LogUtils.d(TAG, "支付成功 - order_number: $orderNumber, capture_type: $captureType, image_id: $imageIdList,fileMap:$fileMap") + + // 发送支付成功事件 + CoroutineScope(Dispatchers.Main).launch { + _uploadPhotoEvents.emit( + UploadPhotoEvent.PaySuccess( + orderNumber = orderNumber, + captureType = captureType, + imageIds = imageIdList, + fileMap = fileMap + ) + ) + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}") + } + } + "4" -> { + // 隐私取片消息 + LogUtils.d(TAG, ">>> 收到隐私取片消息 <<<") + // 显示 Toast 提示 + val msgText = msg.ifEmpty { "收到新的打印任务" } + ToastUtils.show(msgText) + // 如果当前在海报页,则关闭海报页 + if (inactivityManager.shouldShowPoster.value) { + LogUtils.d(TAG, "检测到海报页正在显示,关闭海报页") + inactivityManager.hidePoster() + } + handlePrivacyPrintMessage(jsonObject) + } + else -> { + // 其他消息 + LogUtils.d(TAG, ">>> 收到其他类型消息 <<<") + LogUtils.d(TAG, "消息 code: $code, msg: $msg") + // 通知监听器 + messageListeners.forEach { it.onMessage(text) } + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析 WebSocket 消息失败: ${e.message}") + LogUtils.e(TAG, "原始消息: $text") + // 即使解析失败,也通知监听器原始消息 + messageListeners.forEach { it.onMessage(text) } + } + } + + /** + * 刷新 Token 并重新绑定(收到 100099 Token 无效时调用) + * 服务端告知 token 无效,需获取新 token 后重新发送绑定消息 + */ + private fun refreshTokenAndBind() { + CoroutineScope(Dispatchers.IO).launch { + try { + LogUtils.d(TAG, "Token 无效(100099),获取新 Token 并重新绑定") + val response = networkRepository.getSocketToken().first() + if (response.code == 100000 && response.data != null) { + appStoreDataSource.saveSocketToken(response.data.socketToken) + appStoreDataSource.saveSocketTokenExpiredAt(response.data.expiredAt) + LogUtils.d(TAG, "Token 获取成功,重新发送绑定消息") + sendTokenBindMessage() + } else { + LogUtils.e(TAG, "Token 获取失败: ${response.message}") + } + } catch (e: Exception) { + LogUtils.e(TAG, "Token 获取异常: ${e.message}") + } + } + } + + /** + * 启动心跳包 + */ + private fun startHeartbeat() { + stopHeartbeat() + + // LogUtils.d(TAG, "========== 启动心跳包 ==========") + // LogUtils.d(TAG, "心跳间隔: ${HEARTBEAT_INTERVAL}ms") + // LogUtils.d(TAG, "当前绑定状态: $isBound") + // LogUtils.d(TAG, "WebSocket 状态: ${if (webSocket != null) "已连接" else "未连接"}") + + heartbeatJob = CoroutineScope(Dispatchers.IO).launch { + // LogUtils.d(TAG, "心跳包协程已启动") + var heartbeatCount = 0 + + while (isActive && webSocket != null) { + delay(HEARTBEAT_INTERVAL) + heartbeatCount++ + + // LogUtils.d(TAG, "心跳包循环检查 - 第 $heartbeatCount 次") + // LogUtils.d(TAG, "WebSocket 状态: ${if (webSocket != null) "已连接" else "未连接"}") + // LogUtils.d(TAG, "绑定状态: $isBound") + // LogUtils.d(TAG, "协程是否活跃: $isActive") + + if (webSocket != null && isBound) { + // LogUtils.d(TAG, "条件满足,准备发送心跳包") + sendHeartbeat() + } else { + // LogUtils.i(TAG, "心跳包发送条件不满足:") + // LogUtils.i(TAG, " - WebSocket: ${if (webSocket != null) "存在" else "为null"}") + // LogUtils.i(TAG, " - isBound: $isBound") + // if (!isBound) { + // LogUtils.i(TAG, "Token 尚未绑定成功,等待绑定完成后再发送心跳包") + // } + } + } + + // LogUtils.d(TAG, "心跳包协程已退出") + } + + // LogUtils.d(TAG, "=================================") + } + + /** + * 停止心跳包 + */ + private fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + cancelHeartbeatTimeout() + } + + /** + * 取消心跳包超时检测 + */ + private fun cancelHeartbeatTimeout() { + // if (isWaitingHeartbeatResponse) { + // LogUtils.d(TAG, ">>> 取消心跳包超时检测 <<<") + // LogUtils.d(TAG, "心跳包响应已收到,连接正常") + // } + heartbeatTimeoutJob?.cancel() + heartbeatTimeoutJob = null + isWaitingHeartbeatResponse = false + } + + /** + * 发送心跳包 + * + * 注意:心跳包中的 print_status 表示打印机状态(与 /api/oscar/order/print-notify 接口中的 print_status 含义不同) + * - 1: 可以接受打印任务(空闲) + * - 2: 正在打印,无法接受打印任务 + * - 3: 打印机离线,无法接受打印任务 + * + * 每次发送心跳包时都会重新获取最新的打印机状态 + */ + private fun sendHeartbeat() { + // 如果正在等待上一次心跳包的响应,先取消超时检测 + if (isWaitingHeartbeatResponse) { + // LogUtils.i(TAG, "上一次心跳包响应未收到,取消超时检测并重新发送") + cancelHeartbeatTimeout() + } + + // 获取打印状态 + // status: 1-正常工作, 2-维护中 + // print_status: 1-可以接受打印任务, 2-正在打印, 3-打印机离线 + // 注意:每次发送心跳包时都重新获取最新状态,确保状态实时性 + val status = 1 // 默认正常工作 + val printStatus = getPrintStatus() // 从 PrintStatusManager 获取最新打印机状态 + val remainingPaperNum = appStoreDataSource.getRemainingPaperNum() // 从本地获取剩余打印纸张数 + + val heartbeatMessage = JsonObject().apply { + addProperty("type", 1001) + add("params", JsonObject().apply { + addProperty("status", status) + addProperty("print_status", printStatus) + addProperty("remaining_paper_num", remainingPaperNum) + }) + } + + val messageJson = gson.toJson(heartbeatMessage) + // LogUtils.d(TAG, "========== 发送心跳包 ==========") + // LogUtils.d(TAG, "消息类型: type=1001 (心跳包)") + // LogUtils.d(TAG, "状态: status=$status (1=正常工作, 2=维护中)") + // LogUtils.d(TAG, "打印机状态 print_status=$printStatus (1=空闲可接受任务, 2=正在打印, 3=离线)") + // LogUtils.d(TAG, "消息内容: $messageJson") + // LogUtils.d(TAG, "消息长度: ${messageJson.length} 字符") + val result = sendMessage(messageJson) + if (result) { + // LogUtils.d(TAG, "心跳包发送成功") + // 标记正在等待心跳包响应 + isWaitingHeartbeatResponse = true + // 启动超时检测 + startHeartbeatTimeout() + } else { + // LogUtils.e(TAG, "心跳包发送失败") + } + // LogUtils.d(TAG, "=================================") + } + + /** + * 启动心跳包响应超时检测 + */ + private fun startHeartbeatTimeout() { + // 取消之前的超时检测 + cancelHeartbeatTimeout() + + heartbeatTimeoutJob = CoroutineScope(Dispatchers.IO).launch { + delay(HEARTBEAT_TIMEOUT) + + // 如果超时后仍然在等待响应,说明连接可能有问题 + if (isWaitingHeartbeatResponse && isActive) { + // LogUtils.e(TAG, "========== 心跳包响应超时 ==========") + // LogUtils.e(TAG, "发送心跳包后 ${HEARTBEAT_TIMEOUT}ms 内未收到响应") + // LogUtils.e(TAG, "连接可能已断开,开始重连和重绑定") + // LogUtils.e(TAG, "=====================================") + + // 关闭当前连接 + webSocket?.close(1000, "心跳包响应超时") + webSocket = null + isBound = false + isWaitingHeartbeatResponse = false + + // 停止心跳包 + stopHeartbeat() + + // 获取新 token 并重新连接 + fetchTokenAndStart() + } + } + } + + /** + * 获取 Token 并启动连接(重连时调用) + * 每次 WebSocket 需要连接时获取 token,连接成功后不再刷新 + */ + private fun fetchTokenAndStart() { + CoroutineScope(Dispatchers.IO).launch { + try { + LogUtils.d(TAG, "重连前获取 Socket Token") + val response = networkRepository.getSocketToken().first() + if (response.code == 100000 && response.data != null) { + appStoreDataSource.saveSocketToken(response.data.socketToken) + appStoreDataSource.saveSocketTokenExpiredAt(response.data.expiredAt) + LogUtils.d(TAG, "Token 获取成功,开始连接 WebSocket") + reconnectAttempts = 0 + start() + } else { + LogUtils.e(TAG, "获取 Token 失败: ${response.message},尝试使用本地 token 重连") + reconnectAttempts = 0 + start() + } + } catch (e: Exception) { + LogUtils.e(TAG, "获取 Token 异常: ${e.message},尝试使用本地 token 重连") + reconnectAttempts = 0 + start() + } + } + } + + /** + * 获取打印机状态(用于心跳包) + * + * 每次调用都会从 PrintStatusManager 获取最新状态,确保状态实时性 + * + * @return 打印机状态 + * - 1: 可以接受打印任务(空闲) + * - 2: 正在打印,无法接受打印任务 + * - 3: 打印机离线,无法接受打印任务 + * + * 注意:此返回值用于心跳包,与 /api/oscar/order/print-notify 接口中的 print_status 含义不同 + * print-notify 接口中的 print_status 表示订单打印结果(1=成功,2=失败) + */ + private fun getPrintStatus(): Int { + // 每次发送心跳包时都重新获取最新状态 + val currentStatus = printStatusManager.getPrintStatus() + // LogUtils.d(TAG, "获取打印机状态: $currentStatus (1=空闲, 2=打印中, 3=离线)") + return currentStatus + } + + /** + * 发送消息 + */ + fun sendMessage(message: String): Boolean { + val ws = webSocket + return if (ws != null) { + val result = ws.send(message) + if (result) { + logWebSocketTx(message) + } else { + LogUtils.e(TAG, "[发送失败] WebSocket 消息发送失败") + if (BuildConfig.DEBUG) { + LogUtils.e(TAG, "失败消息内容: $message") + } + } + result + } else { + LogUtils.e(TAG, "[发送失败] WebSocket 未连接,无法发送消息") + if (BuildConfig.DEBUG) { + LogUtils.e(TAG, "失败消息内容: $message") + } + false + } + } + + /** Debug 构建下打印收到的 WebSocket 原文 */ + private fun logWebSocketRx(text: String) { + if (BuildConfig.DEBUG) { + LogUtils.d(TAG, "WS ← recv (${text.length}): $text") + } + } + + /** Debug 构建下打印发送的 WebSocket 原文 */ + private fun logWebSocketTx(message: String) { + if (BuildConfig.DEBUG) { + LogUtils.d(TAG, "WS → send (${message.length}): $message") + } + } + + /** + * 安排重连 + */ + private fun scheduleReconnect() { + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + LogUtils.e(TAG, "达到最大重连次数,停止重连") + return + } + + reconnectJob?.cancel() + reconnectJob = CoroutineScope(Dispatchers.IO).launch { + reconnectAttempts++ + val delay = RECONNECT_DELAY * reconnectAttempts + LogUtils.d(TAG, "等待 $delay 毫秒后重连(第 $reconnectAttempts 次)") + delay(delay) + + if (isActive) { + fetchTokenAndStart() + } + } + } + + /** + * 停止 WebSocket 连接 + */ + fun stop() { + LogUtils.d(TAG, "停止 WebSocket 连接") + scenicLiveSubscribeJob?.cancel() + scenicLiveSubscribeJob = null + reconnectJob?.cancel() + reconnectJob = null + stopHeartbeat() + cancelHeartbeatTimeout() + webSocket?.close(1000, "主动关闭") + webSocket = null + isBound = false + isConnecting = false + isWaitingHeartbeatResponse = false + reconnectAttempts = 0 + } + + /** + * 检查是否已连接 + */ + fun isConnected(): Boolean { + return webSocket != null && isBound + } + + + /** + * code=100000 且 data.action 为景区直播推送时处理,返回 true 表示已消费(不再走心跳/绑定) + */ + private fun tryHandleScenicLiveAction100000(jsonObject: JsonObject): Boolean { + val dataElement = jsonObject.get("data") ?: return false + if (!dataElement.isJsonObject) return false + val dataObj = dataElement.asJsonObject + if (!dataObj.has("action")) return false + return when (dataObj.get("action")?.asString) { + "scenic_live_stop" -> { + LogUtils.d(TAG, "scenic_live_stop,清除直播海报地址") + scenicLivePosterController.clearScenicLive() + true + } + "scenic_live_start" -> { + val params = dataObj.getAsJsonObject("params") + val liveIds = mutableListOf() + params?.get("live_ids")?.asJsonArray?.forEach { el -> + try { + liveIds.add(el.asInt) + } catch (_: Exception) { } + } + LogUtils.d(TAG, "scenic_live_start live_ids=$liveIds,请求 /api/oscar/lives") + CoroutineScope(Dispatchers.IO).launch { + fetchAndApplyOscarLives(liveIds) + } + true + } + else -> { + LogUtils.d(TAG, "data.action=${dataObj.get("action")} 非景区直播,跳过绑定解析") + true + } + } + } + + /** + * Token 绑定成功后延迟 5 秒发送 type=304,订阅景区直播状态 + */ + private fun scheduleScenicLiveSubscribeAfterBind() { + scenicLiveSubscribeJob?.cancel() + scenicLiveSubscribeJob = CoroutineScope(Dispatchers.IO).launch { + delay(SCENIC_LIVE_SUBSCRIBE_DELAY_MS) + if (webSocket != null && isBound) { + sendScenicLiveSubscribeQuery() + } + } + } + + private fun sendScenicLiveSubscribeQuery() { + val sid = appStoreDataSource.getScenicId() + if (sid.isBlank()) { + LogUtils.w(TAG, "scenic_id 为空,跳过 type 304") + return + } + val scenicIdInt = sid.toIntOrNull() + if (scenicIdInt == null) { + LogUtils.w(TAG, "scenic_id 非法: $sid") + return + } + val msg = JsonObject().apply { + addProperty("type", 304) + add("params", JsonObject().apply { + addProperty("scenic_id", scenicIdInt) + }) + } + val json = gson.toJson(msg) + sendMessage(json) + } + + /** + * 根据 scenic_live_start 的 live_ids 与 /api/oscar/lives 结果匹配,保存 play_urls.auto + */ + private suspend fun fetchAndApplyOscarLives(liveIdsFromWs: List) { + if (liveIdsFromWs.isEmpty()) { + LogUtils.w(TAG, "live_ids 为空,跳过拉取 lives") + return + } + try { + val response = networkRepository.getOscarLives("rtmp").first() + if (response.code != 100000 || response.data == null) { + LogUtils.e(TAG, "getOscarLives 失败: code=${response.code} msg=${response.message}") + return + } + val lives = response.data.lives + if (lives.isNullOrEmpty()) { + LogUtils.d(TAG, "lives 为空,清除本地直播地址") + scenicLivePosterController.clearScenicLive() + return + } + var applied = false + for (live in lives) { + val id = live.id ?: continue + if (id !in liveIdsFromWs) continue + val status = live.status ?: continue + if (status == 4) continue + if (status !in listOf(1, 2, 3)) continue + val auto = live.playUrls?.auto + if (!auto.isNullOrBlank()) { + scenicLivePosterController.setDynamicRtmpUrl(auto) + LogUtils.d(TAG, "已保存景区直播地址 liveId=$id status=$status") + applied = true + break + } + } + if (!applied) { + LogUtils.d(TAG, "未匹配到可播放直播(status 1/2/3 或 play_urls.auto)") + scenicLivePosterController.clearScenicLive() + } + } catch (e: Exception) { + LogUtils.e(TAG, "fetchAndApplyOscarLives: ${e.message}") + } + } + + /** + * 处理隐私取片消息 + */ + private fun handlePrivacyPrintMessage(jsonObject: JsonObject) { + try { + val dataElement = jsonObject.getAsJsonObject("data") + val orderNumber = dataElement.get("order_number")?.asString ?: "" + val userPhoneSuffix = dataElement.get("user_phone_suffix")?.asString ?: "" + val captureType = dataElement.get("capture_type")?.asInt ?: 0 + + // 解析 image_id 数组 + val imageIdArray = dataElement.getAsJsonArray("image_id") + val imageIdList = mutableListOf() + imageIdArray?.forEach { element -> + element.asInt?.let { imageIdList.add(it) } + } + + LogUtils.d(TAG, "解析隐私取片消息:") + LogUtils.d(TAG, " 订单号: $orderNumber") + LogUtils.d(TAG, " 手机尾号: $userPhoneSuffix") + LogUtils.d(TAG, " 抓拍类型: $captureType") + LogUtils.d(TAG, " 图片ID列表: $imageIdList") + + val message = PrivacyPrintMessage( + orderNumber = orderNumber, + userPhoneSuffix = userPhoneSuffix, + captureType = captureType, + imageId = imageIdList, + printStatus = PrivacyPrintMessage.STATUS_NOT_STARTED + ) + + // 添加到打印队列 + val isNewTask = printQueueManager.addOrUpdateTask(message) + LogUtils.d(TAG, if (isNewTask) "新任务已添加到队列" else "任务已更新") + + // 如果队列中没有正在处理的任务,开始处理下一个任务 + if (printQueueManager.getCurrentTask() == null) { + CoroutineScope(Dispatchers.IO).launch { + privacyPrintService.processNextTask() + } + } + } catch (e: Exception) { + LogUtils.e(TAG, "解析隐私取片消息失败: ${e.message}") + e.printStackTrace() + } + } +} + +/** + * WebSocket 消息监听器接口 + */ +interface WebSocketMessageListener { + /** + * Token 绑定成功回调 + */ + fun onTokenBound(sn: String?) {} + + /** + * 收到其他消息回调 + */ + fun onMessage(message: String) {} +} + +/** + * 上传照片相关事件 + */ +sealed class UploadPhotoEvent { + /** + * 扫码成功 + * @param uuid 用户UUID + * @param openId 微信OpenID + */ + data class ScanSuccess(val uuid: String?, val openId: String?) : UploadPhotoEvent() + + /** + * 收到文件列表 + * @param fileList 文件列表,包含完整的 file_map 数据 + */ + data class FileListReceived(val fileList: List) : UploadPhotoEvent() + + /** + * 支付成功 + * @param orderNumber 订单号 + * @param captureType 抓拍类型 + * @param imageIds 图片ID数组 + */ + data class PaySuccess( + val orderNumber: String?, + val captureType: Int?, + val fileMap:String, + val imageIds: List + ) : UploadPhotoEvent() +} + diff --git a/app/src/main/java/com/yzx/kiosk/websocket/model/PrivacyPrintMessage.kt b/app/src/main/java/com/yzx/kiosk/websocket/model/PrivacyPrintMessage.kt new file mode 100644 index 0000000..1883027 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/websocket/model/PrivacyPrintMessage.kt @@ -0,0 +1,35 @@ +package com.yzx.kiosk.websocket.model + +import com.google.gson.annotations.SerializedName + +/** + * 隐私取片消息数据模型 + */ +data class PrivacyPrintMessage( + @SerializedName("order_number") + val orderNumber: String, + + @SerializedName("user_phone_suffix") + val userPhoneSuffix: String, + + @SerializedName("capture_type") + val captureType: Int, + + @SerializedName("image_id") + val imageId: MutableList, + + /** + * 打印状态 + * 1 - 未开始打印 + * 2 - 打印中 + * 3 - 打印完成 + */ + var printStatus: Int = 1 +) { + companion object { + const val STATUS_NOT_STARTED = 1 + const val STATUS_PRINTING = 2 + const val STATUS_COMPLETED = 3 + } +} + diff --git a/app/src/main/res/drawable/bg_btn_primary.xml b/app/src/main/res/drawable/bg_btn_primary.xml new file mode 100644 index 0000000..55b759f --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_primary.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_btn_primary_outline.xml b/app/src/main/res/drawable/bg_btn_primary_outline.xml new file mode 100644 index 0000000..5126b73 --- /dev/null +++ b/app/src/main/res/drawable/bg_btn_primary_outline.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_privacy_dialog_card.xml b/app/src/main/res/drawable/bg_privacy_dialog_card.xml new file mode 100644 index 0000000..6921517 --- /dev/null +++ b/app/src/main/res/drawable/bg_privacy_dialog_card.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_accept_orders.xml b/app/src/main/res/drawable/ic_accept_orders.xml new file mode 100644 index 0000000..125343a --- /dev/null +++ b/app/src/main/res/drawable/ic_accept_orders.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_access_time.xml b/app/src/main/res/drawable/ic_access_time.xml new file mode 100644 index 0000000..3003552 --- /dev/null +++ b/app/src/main/res/drawable/ic_access_time.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_alive_pause.xml b/app/src/main/res/drawable/ic_alive_pause.xml new file mode 100644 index 0000000..0c6407d --- /dev/null +++ b/app/src/main/res/drawable/ic_alive_pause.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_alive_play.xml b/app/src/main/res/drawable/ic_alive_play.xml new file mode 100644 index 0000000..c425019 --- /dev/null +++ b/app/src/main/res/drawable/ic_alive_play.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_alive_stop.xml b/app/src/main/res/drawable/ic_alive_stop.xml new file mode 100644 index 0000000..e8e00da --- /dev/null +++ b/app/src/main/res/drawable/ic_alive_stop.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_area_authority.xml b/app/src/main/res/drawable/ic_area_authority.xml new file mode 100644 index 0000000..f19254d --- /dev/null +++ b/app/src/main/res/drawable/ic_area_authority.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_arrow_upward.xml b/app/src/main/res/drawable/ic_arrow_upward.xml new file mode 100644 index 0000000..8a7521f --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_upward.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_assignment.xml b/app/src/main/res/drawable/ic_assignment.xml new file mode 100644 index 0000000..9bca37a --- /dev/null +++ b/app/src/main/res/drawable/ic_assignment.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_attach_money.xml b/app/src/main/res/drawable/ic_attach_money.xml new file mode 100644 index 0000000..5a76e5b --- /dev/null +++ b/app/src/main/res/drawable/ic_attach_money.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_camera_alt.xml b/app/src/main/res/drawable/ic_camera_alt.xml new file mode 100644 index 0000000..523b5b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_alt.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_checked.xml b/app/src/main/res/drawable/ic_checked.xml new file mode 100644 index 0000000..8ab7090 --- /dev/null +++ b/app/src/main/res/drawable/ic_checked.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_close_round.xml b/app/src/main/res/drawable/ic_close_round.xml new file mode 100644 index 0000000..93d8df0 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_round.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_img.xml b/app/src/main/res/drawable/ic_delete_img.xml new file mode 100644 index 0000000..ed683ca --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_img.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..4fda333 --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_face_large.xml b/app/src/main/res/drawable/ic_face_large.xml new file mode 100644 index 0000000..510f605 --- /dev/null +++ b/app/src/main/res/drawable/ic_face_large.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_fun_add.xml b/app/src/main/res/drawable/ic_fun_add.xml new file mode 100644 index 0000000..d7bf40e --- /dev/null +++ b/app/src/main/res/drawable/ic_fun_add.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_fun_remove.xml b/app/src/main/res/drawable/ic_fun_remove.xml new file mode 100644 index 0000000..d0036a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_fun_remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_menu_approve.xml b/app/src/main/res/drawable/ic_home_menu_approve.xml new file mode 100644 index 0000000..561f911 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_approve.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_broadcast.xml b/app/src/main/res/drawable/ic_home_menu_broadcast.xml new file mode 100644 index 0000000..83cb368 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_broadcast.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_calendar.xml b/app/src/main/res/drawable/ic_home_menu_calendar.xml new file mode 100644 index 0000000..df4aa1d --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_calendar.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_cloud.xml b/app/src/main/res/drawable/ic_home_menu_cloud.xml new file mode 100644 index 0000000..33e3bb1 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_cloud.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_fly.xml b/app/src/main/res/drawable/ic_home_menu_fly.xml new file mode 100644 index 0000000..6c5c00e --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_fly.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_live_album.xml b/app/src/main/res/drawable/ic_home_menu_live_album.xml new file mode 100644 index 0000000..fb71285 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_live_album.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_menu_location.xml b/app/src/main/res/drawable/ic_home_menu_location.xml new file mode 100644 index 0000000..efbba83 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_location.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_location_report.xml b/app/src/main/res/drawable/ic_home_menu_location_report.xml new file mode 100644 index 0000000..71accc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_location_report.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_more.xml b/app/src/main/res/drawable/ic_home_menu_more.xml new file mode 100644 index 0000000..0e699d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_more.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_notification.xml b/app/src/main/res/drawable/ic_home_menu_notification.xml new file mode 100644 index 0000000..d3d7f99 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_notification.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_photo.xml b/app/src/main/res/drawable/ic_home_menu_photo.xml new file mode 100644 index 0000000..886f604 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_photo.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_pm.xml b/app/src/main/res/drawable/ic_home_menu_pm.xml new file mode 100644 index 0000000..3abbe75 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_pm.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_point.xml b/app/src/main/res/drawable/ic_home_menu_point.xml new file mode 100644 index 0000000..abc096f --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_point.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_registration_invitation.xml b/app/src/main/res/drawable/ic_home_menu_registration_invitation.xml new file mode 100644 index 0000000..29f2943 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_registration_invitation.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_route.xml b/app/src/main/res/drawable/ic_home_menu_route.xml new file mode 100644 index 0000000..f59aa4c --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_route.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_setting.xml b/app/src/main/res/drawable/ic_home_menu_setting.xml new file mode 100644 index 0000000..b174ca7 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_setting.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_space.xml b/app/src/main/res/drawable/ic_home_menu_space.xml new file mode 100644 index 0000000..7a6ef08 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_space.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_store.xml b/app/src/main/res/drawable/ic_home_menu_store.xml new file mode 100644 index 0000000..37868c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_store.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_task.xml b/app/src/main/res/drawable/ic_home_menu_task.xml new file mode 100644 index 0000000..a32bb17 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_task.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_menu_wallet.xml b/app/src/main/res/drawable/ic_home_menu_wallet.xml new file mode 100644 index 0000000..ad52284 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_menu_wallet.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_scenic_more.xml b/app/src/main/res/drawable/ic_home_scenic_more.xml new file mode 100644 index 0000000..2531d46 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_scenic_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_img_transfer.xml b/app/src/main/res/drawable/ic_img_transfer.xml new file mode 100644 index 0000000..3a00c5c --- /dev/null +++ b/app/src/main/res/drawable/ic_img_transfer.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_invisible.xml b/app/src/main/res/drawable/ic_invisible.xml new file mode 100644 index 0000000..16a73b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_invisible.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_location.xml b/app/src/main/res/drawable/ic_location.xml new file mode 100644 index 0000000..c4fc7cb --- /dev/null +++ b/app/src/main/res/drawable/ic_location.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_map_location.xml b/app/src/main/res/drawable/ic_map_location.xml new file mode 100644 index 0000000..bcfdcc6 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_location.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_zoom_in.xml b/app/src/main/res/drawable/ic_map_zoom_in.xml new file mode 100644 index 0000000..c5c7b00 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_zoom_in.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_map_zoom_out.xml b/app/src/main/res/drawable/ic_map_zoom_out.xml new file mode 100644 index 0000000..f15a638 --- /dev/null +++ b/app/src/main/res/drawable/ic_map_zoom_out.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_marketing.xml b/app/src/main/res/drawable/ic_marketing.xml new file mode 100644 index 0000000..963c8fe --- /dev/null +++ b/app/src/main/res/drawable/ic_marketing.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_more_scenic.xml b/app/src/main/res/drawable/ic_more_scenic.xml new file mode 100644 index 0000000..51b9c42 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_scenic.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_online.xml b/app/src/main/res/drawable/ic_online.xml new file mode 100644 index 0000000..d3cbfdc --- /dev/null +++ b/app/src/main/res/drawable/ic_online.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_open_scenic.xml b/app/src/main/res/drawable/ic_open_scenic.xml new file mode 100644 index 0000000..142cc2d --- /dev/null +++ b/app/src/main/res/drawable/ic_open_scenic.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_order_failed.xml b/app/src/main/res/drawable/ic_order_failed.xml new file mode 100644 index 0000000..2f1b7e1 --- /dev/null +++ b/app/src/main/res/drawable/ic_order_failed.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_order_successful.xml b/app/src/main/res/drawable/ic_order_successful.xml new file mode 100644 index 0000000..bb0e726 --- /dev/null +++ b/app/src/main/res/drawable/ic_order_successful.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_outline.xml b/app/src/main/res/drawable/ic_outline.xml new file mode 100644 index 0000000..002b87f --- /dev/null +++ b/app/src/main/res/drawable/ic_outline.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..73bccbd --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_profile_business_time.xml b/app/src/main/res/drawable/ic_profile_business_time.xml new file mode 100644 index 0000000..09070ca --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_business_time.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_punch_point_code.xml b/app/src/main/res/drawable/ic_punch_point_code.xml new file mode 100644 index 0000000..cfd4ec1 --- /dev/null +++ b/app/src/main/res/drawable/ic_punch_point_code.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_punch_point_delete.xml b/app/src/main/res/drawable/ic_punch_point_delete.xml new file mode 100644 index 0000000..074c7cf --- /dev/null +++ b/app/src/main/res/drawable/ic_punch_point_delete.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_punch_point_location.xml b/app/src/main/res/drawable/ic_punch_point_location.xml new file mode 100644 index 0000000..b9b72f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_punch_point_location.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_qrcode_scanner.xml b/app/src/main/res/drawable/ic_qrcode_scanner.xml new file mode 100644 index 0000000..d570fba --- /dev/null +++ b/app/src/main/res/drawable/ic_qrcode_scanner.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_report_location.xml b/app/src/main/res/drawable/ic_report_location.xml new file mode 100644 index 0000000..73783e6 --- /dev/null +++ b/app/src/main/res/drawable/ic_report_location.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_schedule_item_paper.xml b/app/src/main/res/drawable/ic_schedule_item_paper.xml new file mode 100644 index 0000000..d04f610 --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule_item_paper.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_schedule_item_shop.xml b/app/src/main/res/drawable/ic_schedule_item_shop.xml new file mode 100644 index 0000000..1ca934e --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule_item_shop.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_schedule_item_time.xml b/app/src/main/res/drawable/ic_schedule_item_time.xml new file mode 100644 index 0000000..9b4b9e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_schedule_item_time.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..8183fc2 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_setting_menu_upgrade.xml b/app/src/main/res/drawable/ic_setting_menu_upgrade.xml new file mode 100644 index 0000000..9e1171d --- /dev/null +++ b/app/src/main/res/drawable/ic_setting_menu_upgrade.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_unchecked.xml b/app/src/main/res/drawable/ic_unchecked.xml new file mode 100644 index 0000000..4672746 --- /dev/null +++ b/app/src/main/res/drawable/ic_unchecked.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_upload_large.xml b/app/src/main/res/drawable/ic_upload_large.xml new file mode 100644 index 0000000..761a1a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_upload_large.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_upload_local.xml b/app/src/main/res/drawable/ic_upload_local.xml new file mode 100644 index 0000000..fac0f43 --- /dev/null +++ b/app/src/main/res/drawable/ic_upload_local.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_user_info_avatar_default.xml b/app/src/main/res/drawable/ic_user_info_avatar_default.xml new file mode 100644 index 0000000..4eff201 --- /dev/null +++ b/app/src/main/res/drawable/ic_user_info_avatar_default.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_play.xml b/app/src/main/res/drawable/ic_video_play.xml new file mode 100644 index 0000000..6a473fd --- /dev/null +++ b/app/src/main/res/drawable/ic_video_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_transfer.xml b/app/src/main/res/drawable/ic_video_transfer.xml new file mode 100644 index 0000000..d532604 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_transfer.xml @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_visible.xml b/app/src/main/res/drawable/ic_visible.xml new file mode 100644 index 0000000..53c48bd --- /dev/null +++ b/app/src/main/res/drawable/ic_visible.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/activity_splash.xml b/app/src/main/res/layout/activity_splash.xml new file mode 100644 index 0000000..9b6a934 --- /dev/null +++ b/app/src/main/res/layout/activity_splash.xml @@ -0,0 +1,48 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_privacy_policy.xml b/app/src/main/res/layout/dialog_privacy_policy.xml new file mode 100644 index 0000000..a0cabc8 --- /dev/null +++ b/app/src/main/res/layout/dialog_privacy_policy.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/poster_live_player_view.xml b/app/src/main/res/layout/poster_live_player_view.xml new file mode 100644 index 0000000..8b0f03e --- /dev/null +++ b/app/src/main/res/layout/poster_live_player_view.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/mipmap-hdpi/ic_alive_details_duration.webp b/app/src/main/res/mipmap-hdpi/ic_alive_details_duration.webp new file mode 100644 index 0000000..7ca232b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_alive_details_duration.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_alive_details_watch.webp b/app/src/main/res/mipmap-hdpi/ic_alive_details_watch.webp new file mode 100644 index 0000000..fca76c6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_alive_details_watch.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..43675ff Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1f1a679 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_point_markers.png b/app/src/main/res/mipmap-hdpi/ic_point_markers.png new file mode 100644 index 0000000..63022e2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_point_markers.png differ diff --git a/app/src/main/res/mipmap-hdpi/icon_back_black.webp b/app/src/main/res/mipmap-hdpi/icon_back_black.webp new file mode 100644 index 0000000..a8eb538 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_back_black.webp differ diff --git a/app/src/main/res/mipmap-hdpi/icon_cb_privacy_selected.png b/app/src/main/res/mipmap-hdpi/icon_cb_privacy_selected.png new file mode 100644 index 0000000..f7da447 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_cb_privacy_selected.png differ diff --git a/app/src/main/res/mipmap-hdpi/icon_cb_privacy_unselect.png b/app/src/main/res/mipmap-hdpi/icon_cb_privacy_unselect.png new file mode 100644 index 0000000..d9dd744 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_cb_privacy_unselect.png differ diff --git a/app/src/main/res/mipmap-hdpi/icon_log_out_warning.webp b/app/src/main/res/mipmap-hdpi/icon_log_out_warning.webp new file mode 100644 index 0000000..b685e60 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_log_out_warning.webp differ diff --git a/app/src/main/res/mipmap-hdpi/icon_pwd_invisible.webp b/app/src/main/res/mipmap-hdpi/icon_pwd_invisible.webp new file mode 100644 index 0000000..df79b3b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_pwd_invisible.webp differ diff --git a/app/src/main/res/mipmap-hdpi/icon_pwd_visible.webp b/app/src/main/res/mipmap-hdpi/icon_pwd_visible.webp new file mode 100644 index 0000000..aa8e936 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_pwd_visible.webp differ diff --git a/app/src/main/res/mipmap-hdpi/icon_splash.webp b/app/src/main/res/mipmap-hdpi/icon_splash.webp new file mode 100644 index 0000000..641b779 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/icon_splash.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_alive_details_duration.webp b/app/src/main/res/mipmap-mdpi/ic_alive_details_duration.webp new file mode 100644 index 0000000..6798cc3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_alive_details_duration.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_alive_details_watch.webp b/app/src/main/res/mipmap-mdpi/ic_alive_details_watch.webp new file mode 100644 index 0000000..9544d4d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_alive_details_watch.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4cda4d9 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b892954 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_point_markers.png b/app/src/main/res/mipmap-mdpi/ic_point_markers.png new file mode 100644 index 0000000..787fbee Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_point_markers.png differ diff --git a/app/src/main/res/mipmap-mdpi/icon_back_black.webp b/app/src/main/res/mipmap-mdpi/icon_back_black.webp new file mode 100644 index 0000000..f7eedd3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_back_black.webp differ diff --git a/app/src/main/res/mipmap-mdpi/icon_cb_privacy_selected.png b/app/src/main/res/mipmap-mdpi/icon_cb_privacy_selected.png new file mode 100644 index 0000000..a25101f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_cb_privacy_selected.png differ diff --git a/app/src/main/res/mipmap-mdpi/icon_cb_privacy_unselect.png b/app/src/main/res/mipmap-mdpi/icon_cb_privacy_unselect.png new file mode 100644 index 0000000..2b4e518 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_cb_privacy_unselect.png differ diff --git a/app/src/main/res/mipmap-mdpi/icon_log_out_warning.webp b/app/src/main/res/mipmap-mdpi/icon_log_out_warning.webp new file mode 100644 index 0000000..06cc04d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_log_out_warning.webp differ diff --git a/app/src/main/res/mipmap-mdpi/icon_pwd_invisible.webp b/app/src/main/res/mipmap-mdpi/icon_pwd_invisible.webp new file mode 100644 index 0000000..c3b8972 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_pwd_invisible.webp differ diff --git a/app/src/main/res/mipmap-mdpi/icon_pwd_visible.webp b/app/src/main/res/mipmap-mdpi/icon_pwd_visible.webp new file mode 100644 index 0000000..0fdfe41 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_pwd_visible.webp differ diff --git a/app/src/main/res/mipmap-mdpi/icon_splash.webp b/app/src/main/res/mipmap-mdpi/icon_splash.webp new file mode 100644 index 0000000..21a954f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/icon_splash.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/bg_home.webp b/app/src/main/res/mipmap-xhdpi/bg_home.webp new file mode 100644 index 0000000..ee5649b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/bg_home.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_alive_details_duration.webp b/app/src/main/res/mipmap-xhdpi/ic_alive_details_duration.webp new file mode 100644 index 0000000..a88ba62 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_alive_details_duration.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_alive_details_watch.webp b/app/src/main/res/mipmap-xhdpi/ic_alive_details_watch.webp new file mode 100644 index 0000000..88a03d7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_alive_details_watch.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_face.webp b/app/src/main/res/mipmap-xhdpi/ic_face.webp new file mode 100644 index 0000000..d8e6040 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_face.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_free_traffic.webp b/app/src/main/res/mipmap-xhdpi/ic_free_traffic.webp new file mode 100644 index 0000000..c05a7d9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_free_traffic.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_get_photo.webp b/app/src/main/res/mipmap-xhdpi/ic_get_photo.webp new file mode 100644 index 0000000..6e9be9f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_get_photo.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_invite_benefit_free_cloud.webp b/app/src/main/res/mipmap-xhdpi/ic_invite_benefit_free_cloud.webp new file mode 100644 index 0000000..4960c14 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_invite_benefit_free_cloud.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_invite_benefit_free_editing.webp b/app/src/main/res/mipmap-xhdpi/ic_invite_benefit_free_editing.webp new file mode 100644 index 0000000..3450855 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_invite_benefit_free_editing.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_invite_earnings.webp b/app/src/main/res/mipmap-xhdpi/ic_invite_earnings.webp new file mode 100644 index 0000000..fb2fce9 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_invite_earnings.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_invite_link.webp b/app/src/main/res/mipmap-xhdpi/ic_invite_link.webp new file mode 100644 index 0000000..317fc61 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_invite_link.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_invite_record.webp b/app/src/main/res/mipmap-xhdpi/ic_invite_record.webp new file mode 100644 index 0000000..649e075 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_invite_record.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_invite_share.webp b/app/src/main/res/mipmap-xhdpi/ic_invite_share.webp new file mode 100644 index 0000000..28847ab Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_invite_share.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..95b02a7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..7db9a18 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_location_report.png b/app/src/main/res/mipmap-xhdpi/ic_location_report.png new file mode 100644 index 0000000..6247990 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_location_report.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_material_num.webp b/app/src/main/res/mipmap-xhdpi/ic_material_num.webp new file mode 100644 index 0000000..c9a2273 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_material_num.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_no_commission.webp b/app/src/main/res/mipmap-xhdpi/ic_no_commission.webp new file mode 100644 index 0000000..bd22eaf Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_no_commission.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_note_printer.webp b/app/src/main/res/mipmap-xhdpi/ic_note_printer.webp new file mode 100644 index 0000000..1213803 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_note_printer.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_order_tools.webp b/app/src/main/res/mipmap-xhdpi/ic_order_tools.webp new file mode 100644 index 0000000..09e3681 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_order_tools.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_photo_num.webp b/app/src/main/res/mipmap-xhdpi/ic_photo_num.webp new file mode 100644 index 0000000..4376693 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_photo_num.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_point_markers.png b/app/src/main/res/mipmap-xhdpi/ic_point_markers.png new file mode 100644 index 0000000..b7384d3 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_point_markers.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_preview_close.webp b/app/src/main/res/mipmap-xhdpi/ic_preview_close.webp new file mode 100644 index 0000000..9ab1561 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_preview_close.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_print_end.webp b/app/src/main/res/mipmap-xhdpi/ic_print_end.webp new file mode 100644 index 0000000..236bb7c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_print_end.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_print_failed.webp b/app/src/main/res/mipmap-xhdpi/ic_print_failed.webp new file mode 100644 index 0000000..8098fd5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_print_failed.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_pro_player_pause.webp b/app/src/main/res/mipmap-xhdpi/ic_pro_player_pause.webp new file mode 100644 index 0000000..0cfd4dd Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_pro_player_pause.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_pro_player_play.webp b/app/src/main/res/mipmap-xhdpi/ic_pro_player_play.webp new file mode 100644 index 0000000..0da9796 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_pro_player_play.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_project_person.webp b/app/src/main/res/mipmap-xhdpi/ic_project_person.webp new file mode 100644 index 0000000..147d522 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_project_person.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_scan_succes.webp b/app/src/main/res/mipmap-xhdpi/ic_scan_succes.webp new file mode 100644 index 0000000..48a5bce Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_scan_succes.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_scenic_identity.webp b/app/src/main/res/mipmap-xhdpi/ic_scenic_identity.webp new file mode 100644 index 0000000..240ca12 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_scenic_identity.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_task_user_phone.webp b/app/src/main/res/mipmap-xhdpi/ic_task_user_phone.webp new file mode 100644 index 0000000..3af5e4b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_task_user_phone.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_top_print_end.webp b/app/src/main/res/mipmap-xhdpi/ic_top_print_end.webp new file mode 100644 index 0000000..4195446 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_top_print_end.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_top_print_err.webp b/app/src/main/res/mipmap-xhdpi/ic_top_print_err.webp new file mode 100644 index 0000000..ce907c5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_top_print_err.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_top_printting.webp b/app/src/main/res/mipmap-xhdpi/ic_top_printting.webp new file mode 100644 index 0000000..29df644 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_top_printting.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_upload.webp b/app/src/main/res/mipmap-xhdpi/ic_upload.webp new file mode 100644 index 0000000..165cd0e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_upload.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_user_benefits.webp b/app/src/main/res/mipmap-xhdpi/ic_user_benefits.webp new file mode 100644 index 0000000..d6b5669 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_user_benefits.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_video_number.webp b/app/src/main/res/mipmap-xhdpi/ic_video_number.webp new file mode 100644 index 0000000..22f96d2 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_video_number.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_back_black.webp b/app/src/main/res/mipmap-xhdpi/icon_back_black.webp new file mode 100644 index 0000000..252a027 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_back_black.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_cb_privacy_selected.png b/app/src/main/res/mipmap-xhdpi/icon_cb_privacy_selected.png new file mode 100644 index 0000000..1fbdcca Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_cb_privacy_selected.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_cb_privacy_unselect.png b/app/src/main/res/mipmap-xhdpi/icon_cb_privacy_unselect.png new file mode 100644 index 0000000..6b28483 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_cb_privacy_unselect.png differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_log_out_warning.webp b/app/src/main/res/mipmap-xhdpi/icon_log_out_warning.webp new file mode 100644 index 0000000..48237c7 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_log_out_warning.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_pwd_invisible.webp b/app/src/main/res/mipmap-xhdpi/icon_pwd_invisible.webp new file mode 100644 index 0000000..63cfb89 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_pwd_invisible.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_pwd_visible.webp b/app/src/main/res/mipmap-xhdpi/icon_pwd_visible.webp new file mode 100644 index 0000000..93ef489 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_pwd_visible.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/icon_splash.webp b/app/src/main/res/mipmap-xhdpi/icon_splash.webp new file mode 100644 index 0000000..4136528 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/icon_splash.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/img_qr_admin.webp b/app/src/main/res/mipmap-xhdpi/img_qr_admin.webp new file mode 100644 index 0000000..abdad1d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/img_qr_admin.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/bg_home.webp b/app/src/main/res/mipmap-xxhdpi/bg_home.webp new file mode 100644 index 0000000..57f0980 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/bg_home.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_alive_details_duration.webp b/app/src/main/res/mipmap-xxhdpi/ic_alive_details_duration.webp new file mode 100644 index 0000000..72a65c9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_alive_details_duration.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_alive_details_watch.webp b/app/src/main/res/mipmap-xxhdpi/ic_alive_details_watch.webp new file mode 100644 index 0000000..578792b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_alive_details_watch.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_face.webp b/app/src/main/res/mipmap-xxhdpi/ic_face.webp new file mode 100644 index 0000000..280e9ae Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_face.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_free_traffic.webp b/app/src/main/res/mipmap-xxhdpi/ic_free_traffic.webp new file mode 100644 index 0000000..b23ce45 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_free_traffic.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_get_photo.webp b/app/src/main/res/mipmap-xxhdpi/ic_get_photo.webp new file mode 100644 index 0000000..656737c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_get_photo.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_invite_benefit_free_cloud.webp b/app/src/main/res/mipmap-xxhdpi/ic_invite_benefit_free_cloud.webp new file mode 100644 index 0000000..a8c3167 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_invite_benefit_free_cloud.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_invite_benefit_free_editing.webp b/app/src/main/res/mipmap-xxhdpi/ic_invite_benefit_free_editing.webp new file mode 100644 index 0000000..c68d169 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_invite_benefit_free_editing.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_invite_consultation.webp b/app/src/main/res/mipmap-xxhdpi/ic_invite_consultation.webp new file mode 100644 index 0000000..d497781 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_invite_consultation.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_invite_earnings.webp b/app/src/main/res/mipmap-xxhdpi/ic_invite_earnings.webp new file mode 100644 index 0000000..9fb3800 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_invite_earnings.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_invite_link.webp b/app/src/main/res/mipmap-xxhdpi/ic_invite_link.webp new file mode 100644 index 0000000..e17f64e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_invite_link.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_invite_record.webp b/app/src/main/res/mipmap-xxhdpi/ic_invite_record.webp new file mode 100644 index 0000000..4e701ad Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_invite_record.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_invite_share.webp b/app/src/main/res/mipmap-xxhdpi/ic_invite_share.webp new file mode 100644 index 0000000..92346b7 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_invite_share.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..cdf3ec5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c10c87c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8854bca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_location_report.png b/app/src/main/res/mipmap-xxhdpi/ic_location_report.png new file mode 100644 index 0000000..09e897e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_location_report.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_material_num.webp b/app/src/main/res/mipmap-xxhdpi/ic_material_num.webp new file mode 100644 index 0000000..9b917f5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_material_num.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_no_commission.webp b/app/src/main/res/mipmap-xxhdpi/ic_no_commission.webp new file mode 100644 index 0000000..35eafc1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_no_commission.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_note_printer.webp b/app/src/main/res/mipmap-xxhdpi/ic_note_printer.webp new file mode 100644 index 0000000..291fcf9 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_note_printer.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_order_tools.webp b/app/src/main/res/mipmap-xxhdpi/ic_order_tools.webp new file mode 100644 index 0000000..893c078 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_order_tools.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_photo_num.webp b/app/src/main/res/mipmap-xxhdpi/ic_photo_num.webp new file mode 100644 index 0000000..790a102 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_photo_num.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_point_markers.png b/app/src/main/res/mipmap-xxhdpi/ic_point_markers.png new file mode 100644 index 0000000..adddc36 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_point_markers.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_preview_close.webp b/app/src/main/res/mipmap-xxhdpi/ic_preview_close.webp new file mode 100644 index 0000000..9a3cf9d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_preview_close.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_print_end.webp b/app/src/main/res/mipmap-xxhdpi/ic_print_end.webp new file mode 100644 index 0000000..42659cd Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_print_end.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_print_failed.webp b/app/src/main/res/mipmap-xxhdpi/ic_print_failed.webp new file mode 100644 index 0000000..37d54cf Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_print_failed.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_pro_player_pause.webp b/app/src/main/res/mipmap-xxhdpi/ic_pro_player_pause.webp new file mode 100644 index 0000000..423cec8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_pro_player_pause.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_pro_player_play.webp b/app/src/main/res/mipmap-xxhdpi/ic_pro_player_play.webp new file mode 100644 index 0000000..4ed8b36 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_pro_player_play.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_project_person.webp b/app/src/main/res/mipmap-xxhdpi/ic_project_person.webp new file mode 100644 index 0000000..35da0fb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_project_person.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_scan_succes.webp b/app/src/main/res/mipmap-xxhdpi/ic_scan_succes.webp new file mode 100644 index 0000000..1d273fe Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_scan_succes.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_scenic_identity.webp b/app/src/main/res/mipmap-xxhdpi/ic_scenic_identity.webp new file mode 100644 index 0000000..f8ae0f5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_scenic_identity.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_data_select.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_data_select.png new file mode 100644 index 0000000..25a61ce Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_data_select.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_data_unselect.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_data_unselect.png new file mode 100644 index 0000000..2a92be2 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_data_unselect.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_home_selected.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_home_selected.png new file mode 100644 index 0000000..8aeef0c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_home_selected.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_home_unselect.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_home_unselect.png new file mode 100644 index 0000000..48cedd3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_home_unselect.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_me_select.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_me_select.png new file mode 100644 index 0000000..bf60175 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_me_select.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_me_unselect.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_me_unselect.png new file mode 100644 index 0000000..9c24f06 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_me_unselect.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_order_select.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_order_select.png new file mode 100644 index 0000000..8d10fbc Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_order_select.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_tab_order_unselect.png b/app/src/main/res/mipmap-xxhdpi/ic_tab_order_unselect.png new file mode 100644 index 0000000..366d345 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_tab_order_unselect.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_task_user_phone.webp b/app/src/main/res/mipmap-xxhdpi/ic_task_user_phone.webp new file mode 100644 index 0000000..b1b762a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_task_user_phone.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_top_print_end.webp b/app/src/main/res/mipmap-xxhdpi/ic_top_print_end.webp new file mode 100644 index 0000000..f1e880b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_top_print_end.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_top_print_err.webp b/app/src/main/res/mipmap-xxhdpi/ic_top_print_err.webp new file mode 100644 index 0000000..932b79c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_top_print_err.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_top_printting.webp b/app/src/main/res/mipmap-xxhdpi/ic_top_printting.webp new file mode 100644 index 0000000..a4e43ad Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_top_printting.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_upload.webp b/app/src/main/res/mipmap-xxhdpi/ic_upload.webp new file mode 100644 index 0000000..22c5c46 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_upload.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_user_benefits.webp b/app/src/main/res/mipmap-xxhdpi/ic_user_benefits.webp new file mode 100644 index 0000000..f94aa89 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_user_benefits.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_video_number.webp b/app/src/main/res/mipmap-xxhdpi/ic_video_number.webp new file mode 100644 index 0000000..c7eb4a3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_video_number.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_back_black.webp b/app/src/main/res/mipmap-xxhdpi/icon_back_black.webp new file mode 100644 index 0000000..59976c8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_back_black.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cb_privacy_selected.png b/app/src/main/res/mipmap-xxhdpi/icon_cb_privacy_selected.png new file mode 100644 index 0000000..679c06e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cb_privacy_selected.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cb_privacy_unselect.png b/app/src/main/res/mipmap-xxhdpi/icon_cb_privacy_unselect.png new file mode 100644 index 0000000..a3f2ec6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cb_privacy_unselect.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_chang_role.webp b/app/src/main/res/mipmap-xxhdpi/icon_chang_role.webp new file mode 100644 index 0000000..2f04d1f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_chang_role.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_folder.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_folder.webp new file mode 100644 index 0000000..d29dc2d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_folder.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_photo.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_photo.webp new file mode 100644 index 0000000..bd3c481 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_photo.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_video.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_video.webp new file mode 100644 index 0000000..9eb3bb8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_add_video.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_current_path_arrow.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_current_path_arrow.webp new file mode 100644 index 0000000..8d13b21 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_current_path_arrow.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_delete.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_delete.webp new file mode 100644 index 0000000..1e98bcd Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_delete.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_details.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_details.webp new file mode 100644 index 0000000..e480506 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_details.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_download.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_download.webp new file mode 100644 index 0000000..4716bba Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_download.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_filter.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_filter.webp new file mode 100644 index 0000000..0cbf347 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_filter.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_item_details.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_item_details.webp new file mode 100644 index 0000000..f6b94e1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_item_details.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_move.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_move.webp new file mode 100644 index 0000000..f6802e6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_move.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_play.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_play.webp new file mode 100644 index 0000000..bf46633 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_play.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_search.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_search.webp new file mode 100644 index 0000000..5a8907e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_search.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_share.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_share.webp new file mode 100644 index 0000000..04e35cb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_share.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_folder.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_folder.webp new file mode 100644 index 0000000..39ad171 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_folder.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_photo.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_photo.webp new file mode 100644 index 0000000..b218231 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_photo.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_video.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_video.webp new file mode 100644 index 0000000..8e76249 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_file_type_video.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_folder_type.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_folder_type.webp new file mode 100644 index 0000000..9cdff94 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_folder_type.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_go_home.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_go_home.webp new file mode 100644 index 0000000..8b59e51 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_go_home.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_list_sort.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_list_sort.webp new file mode 100644 index 0000000..e92ed9f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_list_sort.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_show_type_list.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_show_type_list.webp new file mode 100644 index 0000000..2a18633 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_show_type_list.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_show_type_table.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_show_type_table.webp new file mode 100644 index 0000000..f9e640e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_show_type_table.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_transit.webp b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_transit.webp new file mode 100644 index 0000000..bbbcca1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_cloud_storage_transit.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_average_amount.png b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_average_amount.png new file mode 100644 index 0000000..4438a99 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_average_amount.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_get_amount.png b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_get_amount.png new file mode 100644 index 0000000..fac0fda Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_get_amount.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_refund_amount.png b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_refund_amount.png new file mode 100644 index 0000000..47b08ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_refund_amount.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_total_amount.png b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_total_amount.png new file mode 100644 index 0000000..8df2b53 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_total_amount.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_total_order.png b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_total_order.png new file mode 100644 index 0000000..6ee6008 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_data_statistics_total_order.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_input_clear.webp b/app/src/main/res/mipmap-xxhdpi/icon_input_clear.webp new file mode 100644 index 0000000..6f131ee Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_input_clear.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_log_out_warning.webp b/app/src/main/res/mipmap-xxhdpi/icon_log_out_warning.webp new file mode 100644 index 0000000..9842ae6 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_log_out_warning.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_payment_code_refresh.png b/app/src/main/res/mipmap-xxhdpi/icon_payment_code_refresh.png new file mode 100644 index 0000000..f628245 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_payment_code_refresh.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_pwd_invisible.webp b/app/src/main/res/mipmap-xxhdpi/icon_pwd_invisible.webp new file mode 100644 index 0000000..6eda912 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_pwd_invisible.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_pwd_visible.webp b/app/src/main/res/mipmap-xxhdpi/icon_pwd_visible.webp new file mode 100644 index 0000000..cf4de0d Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_pwd_visible.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/icon_splash.webp b/app/src/main/res/mipmap-xxhdpi/icon_splash.webp new file mode 100644 index 0000000..8ba856f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/icon_splash.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/img_qr_admin.webp b/app/src/main/res/mipmap-xxhdpi/img_qr_admin.webp new file mode 100644 index 0000000..d9b3f61 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/img_qr_admin.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/img_square_error_big.png b/app/src/main/res/mipmap-xxhdpi/img_square_error_big.png new file mode 100644 index 0000000..562cdb8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/img_square_error_big.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/img_square_loading_big.png b/app/src/main/res/mipmap-xxhdpi/img_square_loading_big.png new file mode 100644 index 0000000..6f310cf Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/img_square_loading_big.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/bg_home.webp b/app/src/main/res/mipmap-xxxhdpi/bg_home.webp new file mode 100644 index 0000000..a10bcbb Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/bg_home.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/btn_pay_success.png b/app/src/main/res/mipmap-xxxhdpi/btn_pay_success.png new file mode 100644 index 0000000..2d0a204 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/btn_pay_success.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_alive_details_duration.webp b/app/src/main/res/mipmap-xxxhdpi/ic_alive_details_duration.webp new file mode 100644 index 0000000..481fa92 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_alive_details_duration.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_alive_details_watch.webp b/app/src/main/res/mipmap-xxxhdpi/ic_alive_details_watch.webp new file mode 100644 index 0000000..e3de4bf Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_alive_details_watch.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_face.webp b/app/src/main/res/mipmap-xxxhdpi/ic_face.webp new file mode 100644 index 0000000..9bfd0dd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_face.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_face_large.webp b/app/src/main/res/mipmap-xxxhdpi/ic_face_large.webp new file mode 100644 index 0000000..25416eb Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_face_large.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_free_traffic.webp b/app/src/main/res/mipmap-xxxhdpi/ic_free_traffic.webp new file mode 100644 index 0000000..fb82cb2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_free_traffic.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_get_photo.webp b/app/src/main/res/mipmap-xxxhdpi/ic_get_photo.webp new file mode 100644 index 0000000..05df94b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_get_photo.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_invite_benefit_free_cloud.webp b/app/src/main/res/mipmap-xxxhdpi/ic_invite_benefit_free_cloud.webp new file mode 100644 index 0000000..86a9cf2 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_invite_benefit_free_cloud.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_invite_benefit_free_editing.webp b/app/src/main/res/mipmap-xxxhdpi/ic_invite_benefit_free_editing.webp new file mode 100644 index 0000000..fb23a1a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_invite_benefit_free_editing.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_invite_consultation.webp b/app/src/main/res/mipmap-xxxhdpi/ic_invite_consultation.webp new file mode 100644 index 0000000..255d3bf Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_invite_consultation.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_invite_earnings.webp b/app/src/main/res/mipmap-xxxhdpi/ic_invite_earnings.webp new file mode 100644 index 0000000..4403587 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_invite_earnings.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_invite_link.webp b/app/src/main/res/mipmap-xxxhdpi/ic_invite_link.webp new file mode 100644 index 0000000..42e21c5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_invite_link.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_invite_record.webp b/app/src/main/res/mipmap-xxxhdpi/ic_invite_record.webp new file mode 100644 index 0000000..b94e9f5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_invite_record.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_invite_share.webp b/app/src/main/res/mipmap-xxxhdpi/ic_invite_share.webp new file mode 100644 index 0000000..ff08ccc Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_invite_share.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d962c89 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..23da602 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_location_report.png b/app/src/main/res/mipmap-xxxhdpi/ic_location_report.png new file mode 100644 index 0000000..28e6b2b Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_location_report.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_material_num.webp b/app/src/main/res/mipmap-xxxhdpi/ic_material_num.webp new file mode 100644 index 0000000..e703b29 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_material_num.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_no_commission.webp b/app/src/main/res/mipmap-xxxhdpi/ic_no_commission.webp new file mode 100644 index 0000000..68cc265 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_no_commission.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_note_printer.webp b/app/src/main/res/mipmap-xxxhdpi/ic_note_printer.webp new file mode 100644 index 0000000..bfef01d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_note_printer.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_order_tools.webp b/app/src/main/res/mipmap-xxxhdpi/ic_order_tools.webp new file mode 100644 index 0000000..7281594 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_order_tools.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_pay_success.png b/app/src/main/res/mipmap-xxxhdpi/ic_pay_success.png new file mode 100644 index 0000000..4027bc6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_pay_success.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_photo_num.webp b/app/src/main/res/mipmap-xxxhdpi/ic_photo_num.webp new file mode 100644 index 0000000..6d349ff Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_photo_num.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_point_markers.png b/app/src/main/res/mipmap-xxxhdpi/ic_point_markers.png new file mode 100644 index 0000000..e9bf0f8 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_point_markers.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_preview_close.webp b/app/src/main/res/mipmap-xxxhdpi/ic_preview_close.webp new file mode 100644 index 0000000..2c7ec29 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_preview_close.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_print_end.webp b/app/src/main/res/mipmap-xxxhdpi/ic_print_end.webp new file mode 100644 index 0000000..6e71608 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_print_end.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_print_failed.webp b/app/src/main/res/mipmap-xxxhdpi/ic_print_failed.webp new file mode 100644 index 0000000..0b20b8e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_print_failed.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_pro_player_pause.webp b/app/src/main/res/mipmap-xxxhdpi/ic_pro_player_pause.webp new file mode 100644 index 0000000..52385e4 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_pro_player_pause.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_pro_player_play.webp b/app/src/main/res/mipmap-xxxhdpi/ic_pro_player_play.webp new file mode 100644 index 0000000..36de16c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_pro_player_play.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_project_person.webp b/app/src/main/res/mipmap-xxxhdpi/ic_project_person.webp new file mode 100644 index 0000000..f745605 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_project_person.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_scan_succes.webp b/app/src/main/res/mipmap-xxxhdpi/ic_scan_succes.webp new file mode 100644 index 0000000..51c4d21 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_scan_succes.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_scenic_identity.webp b/app/src/main/res/mipmap-xxxhdpi/ic_scenic_identity.webp new file mode 100644 index 0000000..0bbf495 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_scenic_identity.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_task_user_phone.webp b/app/src/main/res/mipmap-xxxhdpi/ic_task_user_phone.webp new file mode 100644 index 0000000..160a906 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_task_user_phone.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_top_print_end.webp b/app/src/main/res/mipmap-xxxhdpi/ic_top_print_end.webp new file mode 100644 index 0000000..6e71608 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_top_print_end.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_top_print_err.webp b/app/src/main/res/mipmap-xxxhdpi/ic_top_print_err.webp new file mode 100644 index 0000000..0b20b8e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_top_print_err.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_top_printting.webp b/app/src/main/res/mipmap-xxxhdpi/ic_top_printting.webp new file mode 100644 index 0000000..bfef01d Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_top_printting.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_upload.webp b/app/src/main/res/mipmap-xxxhdpi/ic_upload.webp new file mode 100644 index 0000000..28b9da3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_upload.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_upload_large.webp b/app/src/main/res/mipmap-xxxhdpi/ic_upload_large.webp new file mode 100644 index 0000000..2855787 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_upload_large.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_user_benefits.webp b/app/src/main/res/mipmap-xxxhdpi/ic_user_benefits.webp new file mode 100644 index 0000000..549eb89 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_user_benefits.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_video_number.webp b/app/src/main/res/mipmap-xxxhdpi/ic_video_number.webp new file mode 100644 index 0000000..8540729 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_video_number.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_back_black.webp b/app/src/main/res/mipmap-xxxhdpi/icon_back_black.webp new file mode 100644 index 0000000..ff1ed70 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_back_black.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_cb_privacy_selected.png b/app/src/main/res/mipmap-xxxhdpi/icon_cb_privacy_selected.png new file mode 100644 index 0000000..7dfce28 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_cb_privacy_selected.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_cb_privacy_unselect.png b/app/src/main/res/mipmap-xxxhdpi/icon_cb_privacy_unselect.png new file mode 100644 index 0000000..c53d8fd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_cb_privacy_unselect.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_log_out_warning.webp b/app/src/main/res/mipmap-xxxhdpi/icon_log_out_warning.webp new file mode 100644 index 0000000..50bd5b5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_log_out_warning.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_pwd_invisible.webp b/app/src/main/res/mipmap-xxxhdpi/icon_pwd_invisible.webp new file mode 100644 index 0000000..cd2d977 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_pwd_invisible.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_pwd_visible.webp b/app/src/main/res/mipmap-xxxhdpi/icon_pwd_visible.webp new file mode 100644 index 0000000..78422d4 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_pwd_visible.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/icon_splash.webp b/app/src/main/res/mipmap-xxxhdpi/icon_splash.webp new file mode 100644 index 0000000..b4b0c96 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/icon_splash.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/img_login_bg.webp b/app/src/main/res/mipmap-xxxhdpi/img_login_bg.webp new file mode 100644 index 0000000..459b660 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/img_login_bg.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/img_qr_admin.webp b/app/src/main/res/mipmap-xxxhdpi/img_qr_admin.webp new file mode 100644 index 0000000..bcf0049 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/img_qr_admin.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/img_splash.png b/app/src/main/res/mipmap-xxxhdpi/img_splash.png new file mode 100644 index 0000000..79043b3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/img_splash.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/logo.png b/app/src/main/res/mipmap-xxxhdpi/logo.png new file mode 100644 index 0000000..411f9bc Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/logo.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/res_printend.gif b/app/src/main/res/mipmap-xxxhdpi/res_printend.gif new file mode 100644 index 0000000..2220845 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/res_printend.gif differ diff --git a/app/src/main/res/raw/face_recognition_failed_page.wav b/app/src/main/res/raw/face_recognition_failed_page.wav new file mode 100644 index 0000000..9a1ac2a Binary files /dev/null and b/app/src/main/res/raw/face_recognition_failed_page.wav differ diff --git a/app/src/main/res/raw/face_recognition_page.wav b/app/src/main/res/raw/face_recognition_page.wav new file mode 100644 index 0000000..dea0bbe Binary files /dev/null and b/app/src/main/res/raw/face_recognition_page.wav differ diff --git a/app/src/main/res/raw/face_recognition_recognizing_page.wav b/app/src/main/res/raw/face_recognition_recognizing_page.wav new file mode 100644 index 0000000..2290176 Binary files /dev/null and b/app/src/main/res/raw/face_recognition_recognizing_page.wav differ diff --git a/app/src/main/res/raw/face_recognition_success_result_page.wav b/app/src/main/res/raw/face_recognition_success_result_page.wav new file mode 100644 index 0000000..ad24f97 Binary files /dev/null and b/app/src/main/res/raw/face_recognition_success_result_page.wav differ diff --git a/app/src/main/res/raw/home_audio.wav b/app/src/main/res/raw/home_audio.wav new file mode 100644 index 0000000..dc4c636 Binary files /dev/null and b/app/src/main/res/raw/home_audio.wav differ diff --git a/app/src/main/res/raw/loading.json b/app/src/main/res/raw/loading.json new file mode 100644 index 0000000..805e166 --- /dev/null +++ b/app/src/main/res/raw/loading.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"","k":"","d":"","tc":"#FFFFFF"},"fr":25,"ip":25,"op":55,"w":800,"h":800,"nm":"Loading #18","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[20]}],"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[-175,46,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-2,0]],"o":[[0,0],[2,0]],"v":[[-268,46],[-82,46]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.2745,0.3608,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[45]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[0]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[45]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[0]},{"t":60,"s":[45]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[55]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[55]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[55]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":125,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Line 5","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":25,"op":80,"st":25,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Line 4","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[470,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":22,"op":77,"st":22,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Line 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":19,"op":74,"st":19,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Line 2","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[330,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":16,"op":71,"st":16,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Line 1","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[260,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":13,"op":68,"st":13,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/app/src/main/res/raw/pay_success_select_photo_print_page.wav b/app/src/main/res/raw/pay_success_select_photo_print_page.wav new file mode 100644 index 0000000..96ba4d3 Binary files /dev/null and b/app/src/main/res/raw/pay_success_select_photo_print_page.wav differ diff --git a/app/src/main/res/raw/print_complete_page.wav b/app/src/main/res/raw/print_complete_page.wav new file mode 100644 index 0000000..daf8f04 Binary files /dev/null and b/app/src/main/res/raw/print_complete_page.wav differ diff --git a/app/src/main/res/raw/printing_page.wav b/app/src/main/res/raw/printing_page.wav new file mode 100644 index 0000000..8cad28c Binary files /dev/null and b/app/src/main/res/raw/printing_page.wav differ diff --git a/app/src/main/res/raw/scan_qrcode_upload_page.wav b/app/src/main/res/raw/scan_qrcode_upload_page.wav new file mode 100644 index 0000000..6e5e587 Binary files /dev/null and b/app/src/main/res/raw/scan_qrcode_upload_page.wav differ diff --git a/app/src/main/res/raw/upload_scan_success_page.wav b/app/src/main/res/raw/upload_scan_success_page.wav new file mode 100644 index 0000000..024da66 Binary files /dev/null and b/app/src/main/res/raw/upload_scan_success_page.wav differ diff --git a/app/src/main/res/raw/upload_unpaid_result_page.wav b/app/src/main/res/raw/upload_unpaid_result_page.wav new file mode 100644 index 0000000..e20303d Binary files /dev/null and b/app/src/main/res/raw/upload_unpaid_result_page.wav differ diff --git a/app/src/main/res/raw/wechat_qrcode_dialog_page.wav b/app/src/main/res/raw/wechat_qrcode_dialog_page.wav new file mode 100644 index 0000000..12e8cda Binary files /dev/null and b/app/src/main/res/raw/wechat_qrcode_dialog_page.wav differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..861b8c5 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,22 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #FF111111 + + #E6F0FF + #4080FF + + + + #465CFF + + #6AA966 + #1EC9B9 + #B2D5FF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..cfa9be0 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7d2d654 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,120 @@ + + + + 自助照片打印 + + 请输入密码 + 隐藏密码 + 显示密码 + +86 + 请输入手机号 + 请输入账号 + 登录 + 验证码登录 + 账号密码登录 + + 输入邀请码 + 为了给您更好的产品体验,请输入邀请码 + 请输入邀请码 + 确认绑定 + 获取邀请码 + 扫码添加管理员企业微信 + 长按保存为图片 + 二维码已保存至相册 + 保存失败,请稍后重试 + 未授予存储权限,无法保存二维码 + + 注册成功后将自动生成邀请码 + 邀请码(选填) + 注册并登录 + 已有账号? + 去登录 + + 景区选择 + 希望开通更多景区权限 + 立即申请 + 搜索景区 + 没有找到心仪的景区 + 申请平台开通新景区 + 搜索 + 当前定位地址 + 距离最近 + 暂未营业 + + 景区入驻申请 + 选择景区 + 可多选 + 请选择您需要申请权限的景区 + 请选择景区 + 新增景区 + 已有景区 + 提交审核 + 提交中… + 没有更多景区了 + 审核中 + 提交时间: + 审核时间: + 审核人: + 审核备注: + 暂无审核信息 + 提交资料后可在此查看审核进度 + 查看审核进度 + 重新定位 + 我的钱包 + 可提现金额 + 累计金额 + 当前余额 + 提现记录 + 收益明细 + 总收益: + 当日收益:%1$s + 最近7天 + 最近30天 + 最近180天 + 提现到银行卡 + 暂无提现记录 + 暂无收益明细 + 预计到账时间 + 审核时间 + 备注信息 + 到账时间 + 提交申请 + 审核中 + 打款中 + 已完成 + 返回 + 暂无打卡点,点击下方按钮即可添加 + 立即添加 + + 暂无项目,点击下方按钮即可添加 + + + 摄影师邀请 + 邀请记录 + 在线咨询 + 邀请二维码 + 邀请码: + 复制 + 分享 + 链接 + 复制链接 + 收益规则 + 新人福利 + 免费剪辑修图 + 免费云盘相册 + 免费线上流量 + 景区运营身份 + 接单软件工具 + 免佣金特权 + 活动有效期: 2026年9月1日 + 佣金有效期: 2028年1月1日 + + 已邀请摄影师 + 暂无邀请记录 + 没有更多 + 去钱包提现 + + + 首页是否开启直播 + 切换新的首页样式 + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4fab853 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..433048d --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..0ee2a78 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..e926e45 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/test/java/com/yzx/kiosk/ExampleUnitTest.kt b/app/src/test/java/com/yzx/kiosk/ExampleUnitTest.kt new file mode 100644 index 0000000..06a0c2d --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.yzx.kiosk + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7f5a754 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,15 @@ +buildscript { + dependencies { + //增加AGC插件配置 + classpath(libs.huawei.agcp) + } +} + +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false +} \ No newline at end of file diff --git a/docs/工程代码审查报告.md b/docs/工程代码审查报告.md new file mode 100644 index 0000000..a6c0cbf --- /dev/null +++ b/docs/工程代码审查报告.md @@ -0,0 +1,307 @@ +# KIOSK 工程代码审查报告 + +审查日期:2026-08-04 +工程包名:`com.yzx.kiosk` +审查范围:工程配置、应用入口、网络、存储、WebSocket、打印、上传下载、版本更新、权限、测试与构建质量。 + +## 总体结论 + +工程当前能够完成 Debug 和 Release 编译,也能安装到真机运行,但暂不建议直接作为正式生产包发布。主要风险集中在: + +1. 正式包签名与密钥管理。 +2. 打印结果和服务端通知的准确性。 +3. 隐私合规及本地敏感数据保护。 +4. WebSocket、上传下载等异步任务的并发可靠性。 +5. 自动化测试和静态检查缺失。 + +## 严重问题 + +### 1. Release 包实际使用 Debug 签名 + +位置:`app/build.gradle.kts:112-151` + +- Debug 和 Release keystore 及其密码直接写在工程配置中。 +- Release 构建在 `signingConfig` 中选择了 `debug` 签名,而非已声明的 `release` 签名。 +- 实际构建并验证后,Debug 和 Release APK 的签名证书 SHA-256 完全相同。 +- 获取仓库内容的人可以使用现有密钥签出能够覆盖正式应用的 APK。 + +建议: + +- 立即停止在仓库中保存 keystore 和密码。 +- 将签名信息改为从安全的 CI Secret、本机环境变量或未纳入版本控制的属性文件读取。 +- 评估现有正式设备是否已经使用该证书,再制定密钥轮换和升级迁移方案。 + +### 2. 设置页密码硬编码在客户端 + +位置: + +- `app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt:162-164` +- `app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt:322-324` + +设置入口使用固定常量密码,并在客户端直接进行字符串比较。APK 被反编译后即可得到密码,进而进入设置页并修改设备密钥等敏感配置。 + +建议: + +- 不在 APK 中保存通用管理密码。 +- 使用服务端校验、每台设备独立凭证或短期动态口令。 +- 增加失败次数限制、审计和必要的锁定机制。 + +### 3. 高德 SDK 被无条件标记为“用户已同意隐私政策” + +位置:`app/src/main/java/com/yzx/kiosk/App.kt:50-54` + +Application 启动时直接调用: + +```kotlin +AMapLocationClient.updatePrivacyShow(this, true, true) +AMapLocationClient.updatePrivacyAgree(this, true) +``` + +工程虽然包含 `PrivacyPolicyDialogFragment`,但当前没有业务代码展示它,也没有持久化和校验用户真实同意状态。 + +建议: + +- 首次启动先展示隐私政策并记录同意状态。 +- 只有用户主动同意后才能通知第三方 SDK 并初始化相关能力。 +- 用户拒绝时不得初始化非必要 SDK,并提供可用的退出或受限模式。 + +### 4. 打印成功判定不可信 + +位置:`app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt:253-296` + +- RX1 将任务加入 SDK 队列后立即报告成功。 +- QW410 固定等待 18 秒后报告成功。 +- 两种情况都没有读取打印任务的最终完成或失败状态。 + +缺纸、卡纸、断连或 SDK 后续执行失败时,业务仍可能记录为打印成功,并进一步扣减纸张和通知服务端。 + +建议: + +- 监听打印 SDK 的任务状态或结果回调。 +- 只在收到最终成功状态后更新本地记录、纸张数量及服务端状态。 +- 为超时、离线、卡纸、缺纸和用户取消建立明确的错误映射。 + +### 5. 打印状态通知没有真正串行执行 + +位置: + +- `app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:107-143` +- `app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:367-410` +- `app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt:27-73` + +`PrintingViewModel` 使用 Channel 设计了通知队列,但 `executePrintNotify` 调用的 `handleResultWithData` 会再启动一个协程并立即返回。因此队列只保证发起顺序,不保证网络请求完成顺序。 + +可能产生: + +- `print-complete` 先于部分 `print-notify` 到达服务端。 +- 网络失败后没有持久化重试。 +- 本地纸张数在服务端确认前已扣减,失败后不会回滚。 + +建议: + +- 让单次通知成为真正可等待的挂起调用。 +- 只有接口确认成功后再消费下一条通知。 +- 将待通知状态持久化到 Room,并增加幂等键、重试和恢复机制。 + +### 6. 启动后可能错误报告打印机在线 + +位置: + +- `app/src/main/java/com/yzx/kiosk/priter/PrintStatusManager.kt:24-30` +- `app/src/main/java/com/yzx/kiosk/MainActivity.kt:93-100` +- `app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:565-575` + +`PrintStatusManager` 默认打印机在线,而 MainActivity 启动后会立即连接 WebSocket。打印机只在进入打印或管理流程后才初始化,因此没有连接打印机时,心跳仍可能报告“空闲可接单”。 + +建议: + +- 默认状态改为离线或未知。 +- 应用启动时主动初始化并探测打印机。 +- 只有获得可靠设备状态后才报告可接单。 + +## 高优先级问题 + +### 7. 敏感数据使用未加密 MMKV 保存 + +位置: + +- `app/src/main/java/com/yzx/kiosk/utils/MMKVUtils.kt` +- `app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt:48-68` +- `app/src/main/AndroidManifest.xml:52-64` + +设备密钥、Socket Token、盒子 API Token 等数据均存入默认 MMKV,没有加密。同时应用开启 `allowBackup`,备份规则仍是空模板,存在敏感数据被备份或迁移的风险。 + +建议: + +- 使用 Android Keystore 保护的密钥加密敏感字段。 +- 明确排除 MMKV、数据库和凭证文件的云备份与设备迁移。 +- 对 Token 设置有效期,并支持服务端吊销。 + +### 8. APK 更新只校验文件长度 + +位置:`app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt:312-384, 535-557` + +- 已存在 APK 只比较 Content-Length。 +- HEAD 请求无法获取文件大小时会直接使用本地文件。 +- 新下载文件同样只验证长度,没有 SHA-256 或可信证书白名单校验。 + +建议: + +- 版本接口下发 APK 的 SHA-256,并在安装前严格比对。 +- 校验 APK 包名、版本号和签名证书摘要。 +- 下载到临时文件,校验通过后再原子替换正式文件。 + +### 9. 下载任务的并发模型互相冲突 + +位置: + +- `app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadRepository.kt:26-167` +- `app/src/main/java/com/yzx/kiosk/utils/cloud/CloudOssUtils.kt:422-509` + +`DownloadRepository` 用 Map 表示可以同时运行多个下载任务,但 `CloudOssUtils` 只保存一个全局 `currentDownloadTask`。暂停或取消某个任务时,可能取消最后启动的另一个任务。 + +建议: + +- 每个任务持有独立 OSS 请求对象和取消句柄。 +- 或显式限制为单任务队列,不暴露并发能力。 +- 使用互斥锁或单线程调度器保护任务状态。 + +### 10. ResponseInterceptor 篡改所有错误响应的数据结构 + +位置:`app/src/main/java/com/yzx/kiosk/network/interceptor/ResponseInterceptor.kt:27-44` + +所有业务失败响应都会被强制修改为: + +```json +{"data": {}} +``` + +当接口声明的 `data` 类型是字符串、列表或其他非对象类型时,Retrofit/Gson 可能在业务错误处理之前反序列化失败,最终把业务错误误判为网络异常。同时原始错误数据也会丢失。 + +建议: + +- 不在拦截器中修改业务 JSON。 +- 让 `data` 保持可空,并在统一结果层根据 `code` 处理。 +- 为不同错误响应建立兼容测试样例。 + +### 11. Room 数据库可能破坏性清库 + +位置:`app/src/main/java/com/yzx/kiosk/network/db/DatabaseModule.kt:20-28` + +数据库当前只提供 `7 -> 8` 迁移,其他版本使用 `fallbackToDestructiveMigration()`。老版本升级时可能直接清除未完成上传任务。 + +此外两个数据库都声明了 `exportSchema = true`,但未配置 `room.schemaLocation`,构建时已出现警告,也没有生成可纳入版本管理的 schema。 + +建议: + +- 补齐所有已发布版本的迁移链。 +- 移除生产数据库的破坏性迁移回退。 +- 导出 schema 并编写 MigrationTestHelper 测试。 + +### 12. WebSocket 状态存在多线程竞争 + +位置:`app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt` + +OkHttp 回调线程和主线程会同时读写 `webSocket`、`isBound`、`isConnecting`、重连计数和普通 `mutableListOf` 监听器。当前缺少统一协程作用域、线程封闭或锁保护。 + +可能造成重复重连、旧连接回调覆盖新连接状态、遍历监听器时被修改等问题。 + +建议: + +- 建立一个 Application 级 SupervisorJob。 +- 将所有连接状态归一到单线程调度器或 Mutex 保护的状态机。 +- 使用线程安全监听器集合,或统一切换到主线程通知。 + +## 工程质量与维护问题 + +### Lint 当前失败 + +执行 `:app:lintDebug` 的结果: + +- 4 errors +- 345 warnings +- 8 hints + +4 个错误包括: + +1. Target Android 13+ 时缺少 `POST_NOTIFICATIONS` 权限处理。 +2. `AppNavHost.kt` 使用 Media3 不稳定 API 但没有 opt-in。 +3. `PosterScreenWithLiveOrSlideshow.kt` 两处使用 Media3 不稳定 API但没有 opt-in。 + +完整报告:`app/build/reports/lint-results-debug.html` + +### 自动化测试基本为空 + +- 本地单元测试只有 `2 + 2 = 4` 示例。 +- Instrumentation 测试仍断言旧包名 `com.zhifly.follow`,当前包名为 `com.yzx.kiosk`,运行后会失败。 +- 打印状态、通知顺序、数据库迁移、WebSocket 重连、版本校验等核心逻辑均无测试覆盖。 + +### 权限申请过多 + +位置:`app/src/main/AndroidManifest.xml` + +Manifest 申请了后台定位、电话状态、忽略电池优化、旧版外部存储等权限,但工程中未找到对应业务使用。应用同时全局允许明文流量,扩大了安全与合规风险。 + +建议按真实业务最小化权限,并用 `networkSecurityConfig` 只对白名单局域网地址放开明文流量。 + +### Release 包体积偏大 + +实际构建的 Release APK 约为 144 MB。 + +主要原因包括: + +- Release 未开启代码压缩和资源压缩。 +- 同时配置多个 ABI。 +- 本地库约 67 MB,高德 native 库占比较高。 +- 音频资源约 24 MB。 +- 存在重复依赖和不应作为运行时依赖的构建工具依赖。 + +建议使用 ABI Split/App Bundle、开启 R8 和资源压缩,并清理重复或无效依赖。 + +### 核心类过大 + +部分文件行数: + +| 文件 | 行数 | +| --- | ---: | +| `DeviceConfigViewModel.kt` | 1574 | +| `DeviceConfigScreen.kt` | 1024 | +| `HomeScreen.kt` | 996 | +| `WebSocketService.kt` | 974 | +| `VersionUpdateManager.kt` | 811 | +| `PrivacyPrintService.kt` | 673 | +| `UploadManager.kt` | 589 | + +建议按设备配置、上传、播放器、连接状态机、打印订单等职责拆分,并将业务流程从 Composable 和 ViewModel 中下沉到可测试的 UseCase/Repository。 + +### Gradle Wrapper 不可直接运行 + +根目录的 `gradlew` 使用 CRLF 换行且没有执行权限,在 macOS/Linux 上会出现解析错误或 Permission denied。 + +建议恢复标准 Wrapper 文件、使用 LF 换行并设置可执行权限,同时在 CI 中验证 `./gradlew tasks`。 + +## 验证结果 + +| 项目 | 结果 | +| --- | --- | +| Debug 编译 | 通过 | +| Debug 真机安装启动 | 通过 | +| Release 编译 | 通过 | +| Release APK 大小 | 约 144 MB | +| Debug/Release 签名对比 | 相同证书 | +| 单元测试 | 1 个示例测试通过 | +| Lint | 失败,4 errors / 345 warnings / 8 hints | +| Instrumentation 测试 | 未执行;代码中的包名断言确定错误 | + +## 建议处理顺序 + +1. 移除仓库签名密钥和密码,修正 Release 签名,评估密钥轮换。 +2. 移除固定管理密码,改为可靠的设备管理认证。 +3. 修复打印完成判定、通知顺序、纸张扣减和失败重试。 +4. 启动时真实探测打印机,避免错误报告可接单。 +5. 补齐隐私授权流程,收敛权限和明文网络范围。 +6. 加密设备密钥及 Token,关闭敏感数据备份。 +7. 增加 APK 哈希与签名校验。 +8. 修复上传下载及 WebSocket 并发状态管理。 +9. 修复 Lint、数据库迁移和测试基础设施。 +10. 拆分大类、清理依赖并优化 APK 体积。 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3df06d1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,25 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +#org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.enableJetifier=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..824e841 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,260 @@ +[versions] + +agp = "8.10.0" +kotlin = "2.1.10" +coreKtx = "1.15.0" + +junit = "4.13.2" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +composeBom = "2025.05.01" +constraintlayoutCompose = "1.1.1" + +navigationCompose = "2.8.9" + +okhttp = "4.12.0" +retrofit = "2.11.0" + +gson = "2.13.1" + +loggingInterceptor = "4.12.0" + +hilt = "2.56" +hiltNavigationCompose = "1.2.0" +ksp = "2.1.10-1.0.31" + +coilCompose = "2.7.0" + +appcompat = "1.7.0" + +toaster = "12.8" + +mmkv = "2.1.0" + +accompanist-permissions = "0.28.0" +accompanist-systemuicontroller = "0.28.0" + +leakcanaryAndroid = "2.14" + +# lottie +lottieCompose = "6.3.0" + +androidxCoreSplashscreen = "1.0.1" + +materialIcon = "1.7.8" + +zxing = "3.5.3" + +wheelPickerCompose = "1.1.11" + +ucrop = "2.2.10" + +media3Exoplayer = "1.7.1" + +barcodeScanning = "17.3.0" + +camerax = "1.4.2" + +aliyun_sdk = "2.9.21" +aliyuntts_sdk = "2.1.6" + +# Room 核心库 +room-runtime = "2.6.1" +room-ktx = "2.6.1" +room-compiler = "2.6.1" + +#图片选择器 +lucksiege-pictureselector = "v3.11.3" +lucksiege-compress = "v3.11.3" +lucksiege-ucrop = "v3.11.3" +lucksiege-camerax = "v3.11.3" + +glide = "4.12.0" + +lifecycle-viewmodel-ktx = "2.8.4" + +lifecycle-viewmodel-compose = "2.8.4" +androidx-activityCompose = "1.9.2" + +huawei-agcp = "1.6.0.300" +jpush = "5.8.0" +hms-push = "6.13.0.300" +localbroadcastmanager = "1.1.0" +paging = "3.3.6" + +commons-codec = "1.6" +annotation = "1.1.0" +foundation = "1.9.4" + +# JAXB for Android (解决 javax.xml.bind.DatatypeConverter 不可用问题) +jaxb-api = "2.3.1" +jaxb-runtime = "2.3.1" +xerces = "2.12.2" + +# 高德地图 +amap-3dmap = "10.1.600" +amap-search = "9.7.4" +amap-location = "6.5.1" +amap-combined = "10.1.600_9.7.4_6.5.1_20251020" + +# 打印机 +printer-dnpext = "1.0" +printer-colorcnv = "1.2" +printer-photoprintlib = "1.2" +firebaseCrashlyticsBuildtools = "3.0.6" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } + +material-icon = { group = "androidx.compose.material", name = "material-icons-core-android", version.ref = "materialIcon" } +material-icon-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIcon" } + +constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" } + +navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# okhttp +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } + +# retrofit +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } + +# Gson +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } + +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } + +# KSP +ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } +hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } + +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } + +toaster = { group = "com.github.getActivity", name = "Toaster", version.ref = "toaster" } + +mmkv = { group = "com.tencent", name = "mmkv", version.ref = "mmkv" } + +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist-permissions" } +accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist-systemuicontroller" } + +# LeakCanary - https://github.com/square/leakcanary +leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } + +# lottie +lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottieCompose" } + +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" } + +google-zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } + +ucrop = { group = "com.github.yalantis", name = "ucrop", version.ref = "ucrop" } + +wheelPicker_compose = { group = "com.github.commandiron", name = "WheelPickerCompose", version.ref = "wheelPickerCompose" } + +androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } +androidx-media3-datasource-rtmp = { module = "androidx.media3:media3-datasource-rtmp", version.ref = "media3Exoplayer" } +androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" } + +barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } + +# camera +camerax-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +camerax-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } + +# aliyun OSS +aliyun = { module = "com.aliyun.dpa:oss-android-sdk", version.ref = "aliyun_sdk" } + +# aliyun TTS +aliyuntts = { module = "com.alibaba.nls:nls-sdk-common", version.ref = "aliyuntts_sdk" } + +# Room 核心库 +room-runtime = { module = "androidx.room:room-runtime", version.ref = "room-runtime" } +room-ktx = { module = "androidx.room:room-ktx", version.ref = "room-ktx" } +room-compiler-ksp = { module = "androidx.room:room-compiler", version.ref = "room-compiler" } + +lucksiege-pictureselector = { module = "io.github.lucksiege:pictureselector", version.ref = "lucksiege-pictureselector" } +lucksiege-compress = { module = "io.github.lucksiege:compress", version.ref = "lucksiege-compress" } +#lucksiege-ucrop = { module = "io.github.lucksiege:ucrop",version.ref ="lucksiege-ucrop"} +lucksiege-camerax = { module = "io.github.lucksiege:camerax", version.ref = "lucksiege-camerax" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } + + +lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle-viewmodel-ktx" } +lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-viewmodel-compose" } +androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } + +jpush = { module = "cn.jiguang.sdk:jpush", version.ref = "jpush" } +#华为 +huawei-push = { module = "com.huawei.hms:push", version.ref = "hms-push" } +jpush-huawei = { module = "cn.jiguang.sdk.plugin:huawei", version.ref = "jpush" } +# 华为开放平台-AppGallery Connect(AGC插件) +# https://developer.huawei.com/consumer/cn/doc/AppGallery-connect-Guides/agc-introduction-0000001057492641 +huawei-agcp = { module = "com.huawei.agconnect:agcp", version.ref = "huawei-agcp" } + +#接入魅族厂商 +jpush-meizu = { module = "cn.jiguang.sdk.plugin:meizu", version.ref = "jpush" } + +#接入 VIVO 厂商 +jpush-vivo = { module = "cn.jiguang.sdk.plugin:vivo", version.ref = "jpush" } + +#接入小米厂商 +jpush-xiaomi = { module = "cn.jiguang.sdk.plugin:xiaomi", version.ref = "jpush" } + +#接入 OPPO 厂商 +jpush-oppo = { module = "cn.jiguang.sdk.plugin:oppo", version.ref = "jpush" } + +#以下为 OPPO 3.1.0 aar需要依赖 +oppo-push-commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec" } +oppo-push-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } + +#接入荣耀厂商 +jpush-honor = { module = "cn.jiguang.sdk.plugin:honor", version.ref = "jpush" } + +androidx-localbroadcastmanager = { module = "androidx.localbroadcastmanager:localbroadcastmanager", version.ref = "localbroadcastmanager" } + +paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } +paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } + +# JAXB for Android (解决 javax.xml.bind.DatatypeConverter 不可用问题) +jaxb-api = { module = "javax.xml.bind:jaxb-api", version.ref = "jaxb-api" } +jaxb-runtime = { module = "org.glassfish.jaxb:jaxb-runtime", version.ref = "jaxb-runtime" } +xerces = { module = "xerces:xercesImpl", version.ref = "xerces" } + +# 高德地图 - 本地 JAR 文件 +amap-combined-jar = { group = "local", name = "AMap3DMap_AMapSearch_AMapLocation", version.ref = "amap-combined" } + +# 打印机 - 本地 AAR 文件 +printer-dnpext-aar = { group = "local", name = "dnpext", version.ref = "printer-dnpext" } +printer-colorcnv-aar = { group = "local", name = "dnpphotocolorcnv", version.ref = "printer-colorcnv" } +printer-photoprintlib-aar = { group = "local", name = "dnpphotoprintlib", version.ref = "printer-photoprintlib" } +firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } + + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..992cb8a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +#Fri Jun 13 21:52:53 CST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +#distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..02640cb --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..11fcdba --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + // 顺序建议这样:先官方,再华为 + gradlePluginPortal() + google() + mavenCentral() + // 供“插件解析”使用(必须) + maven { url = uri("https://developer.huawei.com/repo/") } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + // 运行时依赖也需要华为仓库 + maven { url = uri("https://developer.huawei.com/repo/") } + maven { url = uri("https://jitpack.io") } + maven { url = uri("https://maven.aliyun.com/repository/public") } + // 如需本地 AAR,再加 flatDir;否则别加在 pluginManagement 里 + flatDir { dirs("libs") } + } +} + +rootProject.name = "KIOSK" +include(":app")