first commit
This commit is contained in:
+23
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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<File> {
|
||||
return listOf(Amap.getCombinedJar(projectDir))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有打印机依赖
|
||||
*/
|
||||
fun getPrinterDependencies(projectDir: File): List<File> {
|
||||
return listOf(
|
||||
Printer.getDnpext(projectDir),
|
||||
Printer.getColorcnv(projectDir),
|
||||
Printer.getPhotoprintlib(projectDir)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音合成依赖
|
||||
*/
|
||||
fun getNui(projectDir: File): List<File> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+35
@@ -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 { *; }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- 安装应用权限 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
|
||||
<!--允许获取精确位置,精准定位必选-->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<!--允许获取粗略位置,粗略定位必选-->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<!--允许获取网络状态,用于网络定位(无gps情况下的定位),若需网络定位功能则必选-->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!--允许获取wifi状态改变,用于网络定位(无gps情况下的定位),若需网络定位功能则必选-->
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
|
||||
<!--后台获取位置信息,若需后台定位则必选-->
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
|
||||
<!--用于申请调用A-GPS模块,卫星定位加速-->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" />
|
||||
|
||||
<!--允许写设备缓存,用于问题排查-->
|
||||
<!-- <uses-permission android:name="android.permission.WRITE_SETTINGS" />-->
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- 开机自启动权限 -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- Android 8.0+ 需要请求安装权限才能自启动(某些厂商) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/logo"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ZhiFlyFollow"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".SplashActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.FULL"
|
||||
tools:ignore="DiscouragedApi,LockedOrientationActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.ZhiFlyFollow"
|
||||
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.crop.MyUCropActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/UCropTheme.Normal"
|
||||
tools:ignore="DiscouragedApi,LockedOrientationActivity" />
|
||||
|
||||
<meta-data android:name="com.amap.api.v2.apikey" android:value="79e82527e55a4927992f79efe93c112b"/>
|
||||
<service android:name="com.amap.api.location.APSService" />
|
||||
|
||||
<!-- 开机广播接收器 -->
|
||||
<!-- <receiver-->
|
||||
<!-- android:name=".receiver.BootReceiver2"-->
|
||||
<!-- android:enabled="true"-->
|
||||
<!-- android:exported="true">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="android.intent.action.BOOT_COMPLETED" />-->
|
||||
<!-- <!– 可选:有些设备用这个广播 –>-->
|
||||
<!-- <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- </receiver>-->
|
||||
|
||||
<!-- 开机自启动广播接收器 -->
|
||||
<receiver
|
||||
android:name=".receiver.BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<!-- 开机完成广播 - 主要广播 -->
|
||||
<intent-filter android:priority="1000">
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<!-- 应用更新后重启(Android 12+) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
<!-- 应用更新后重启(需要 data scheme) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||
<data android:scheme="package" />
|
||||
</intent-filter>
|
||||
<!-- 某些厂商自定义的开机广播 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<View>(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)
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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<Boolean> = _isPlaying.asStateFlow()
|
||||
|
||||
// 当前播放的音频URL
|
||||
private val _currentUrl = MutableStateFlow<String?>(null)
|
||||
val currentUrl: StateFlow<String?> = _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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean> = _isPlaying.asStateFlow()
|
||||
|
||||
// 当前播放的音频资源ID
|
||||
private val _currentResId = MutableStateFlow<Int?>(null)
|
||||
val currentResId: StateFlow<Int?> = _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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Any>) {
|
||||
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<String, Any>) {
|
||||
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, Any>): 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 <T> handleResultWithData(
|
||||
flow: Flow<Result<NetworkResponse<T>>>,
|
||||
showToast: Boolean = true,
|
||||
onLoading: () -> Unit = {},
|
||||
onData: (T) -> Unit,
|
||||
onDataResponse: (NetworkResponse<T>) -> 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = 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, Any?>): String {
|
||||
return params.toSortedMap().entries.joinToString("&") { (k, v) ->
|
||||
percentEncode(k) + "=" + percentEncode(v?.toString() ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
fun popSign(method: String, params: Map<String, Any?>, 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<String, Any?> {
|
||||
|
||||
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<Map<String, Any>>() {}.type)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("HTTP $code non-JSON response: $body", e)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// fun httpPostJsonRaw(
|
||||
// url: String,
|
||||
// jsonBody: Any,
|
||||
// headers: List<String> = 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 == '['
|
||||
// }
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<DialogProps?>(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)?
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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<AppStateManager> // 使用 Lazy 避免潜在的循环依赖
|
||||
) {
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
// 打印状态文本(全局共享)
|
||||
private val _printingStatus = MutableStateFlow<String>("当前无小程序用户操作打印。打印时,不影响当前屏幕操作")
|
||||
val printingStatus: StateFlow<String> = _printingStatus.asStateFlow()
|
||||
|
||||
// 剩余打印张数(全局共享)
|
||||
private val _remainingPaperNum = MutableStateFlow(0)
|
||||
val remainingPaperNum: StateFlow<Int> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<String>) {
|
||||
MMKVUtils.putList(KEY_POSTER_LIST, list)
|
||||
}
|
||||
|
||||
fun getPosterList(): List<String> {
|
||||
return MMKVUtils.getList<String>(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<com.yzx.kiosk.priter.PrintRecord>) {
|
||||
MMKVUtils.putList(KEY_PRINT_RECORDS, records)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取打印记录列表,如果没有则返回空列表
|
||||
*/
|
||||
fun getPrintRecords(): List<com.yzx.kiosk.priter.PrintRecord> {
|
||||
return MMKVUtils.getList<com.yzx.kiosk.priter.PrintRecord>(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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()` 延迟后调用<br>② **心跳包响应超时**时:`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 并重新发送绑定消息,连接不断开
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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<List<FaceSearchResult>>() {}.type
|
||||
Gson().fromJson<List<FaceSearchResult>>(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()
|
||||
}
|
||||
}
|
||||
@@ -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<NavigationEvent>()
|
||||
val navigationEvents: SharedFlow<NavigationEvent> = _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<String, Any>) {
|
||||
_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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Any>? = null
|
||||
) : NavigationEvent()
|
||||
|
||||
data class NavigateBackTo(
|
||||
val route: String,
|
||||
val inclusive: Boolean = false
|
||||
) : NavigationEvent()
|
||||
|
||||
data class CloseAllExcept(
|
||||
val route: String
|
||||
) : NavigationEvent()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<List<UploadTaskEntity>> {
|
||||
return uploadTaskDao.observeAllTasks()
|
||||
}
|
||||
|
||||
suspend fun getPendingUploadTasks(): List<UploadTaskEntity> {
|
||||
return uploadTaskDao.getTasksByStatuses(listOf(UploadStatus.PENDING, UploadStatus.PAUSED))
|
||||
}
|
||||
|
||||
suspend fun getUploadTaskById(taskId: String): UploadTaskEntity? {
|
||||
return uploadTaskDao.getTaskById(taskId)
|
||||
}
|
||||
|
||||
suspend fun insertOrUpdateUploadTasks(tasks: List<UploadTaskEntity>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<UploadTaskEntity>>
|
||||
|
||||
@Query("SELECT * FROM upload_tasks WHERE status != :excludeStatus ORDER BY updatedAt DESC")
|
||||
fun observeActiveTasks(excludeStatus: UploadStatus = UploadStatus.COMPLETED): Flow<List<UploadTaskEntity>>
|
||||
|
||||
@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<UploadTaskEntity>)
|
||||
|
||||
@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<UploadStatus>): List<UploadTaskEntity>
|
||||
|
||||
@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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.yzx.kiosk.network.enums
|
||||
|
||||
enum class UploadStatus {
|
||||
/**
|
||||
* 等待上传
|
||||
*/
|
||||
PENDING, // 等待上传
|
||||
|
||||
/**
|
||||
* 正在上传
|
||||
*/
|
||||
UPLOADING, // 正在上传
|
||||
|
||||
/**
|
||||
* 已暂停
|
||||
*/
|
||||
PAUSED, // 已暂停
|
||||
|
||||
/**
|
||||
* 已完成
|
||||
*/
|
||||
COMPLETED, // 已完成
|
||||
|
||||
/**
|
||||
* 上传失败
|
||||
*/
|
||||
FAILED // 上传失败
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UploadTaskEntity>()
|
||||
|
||||
/**
|
||||
* 当前正在执行的上传任务Job。
|
||||
*/
|
||||
private var currentJob: Job? = null
|
||||
|
||||
|
||||
/**
|
||||
* 正在上传的任务列表(状态流)
|
||||
*/
|
||||
private val _uploadTasks = MutableStateFlow<List<UploadTaskEntity>>(emptyList())
|
||||
val uploadTasks: StateFlow<List<UploadTaskEntity>> = _uploadTasks
|
||||
|
||||
/**
|
||||
* 已完成的任务列表(状态流)
|
||||
*/
|
||||
private val _completedTasks = MutableStateFlow<List<UploadTaskEntity>>(emptyList())
|
||||
val completedTasks: StateFlow<List<UploadTaskEntity>> = _completedTasks
|
||||
|
||||
/**
|
||||
* 上传成功事件流(用于通知上传成功,特别是音频和海报上传)
|
||||
*/
|
||||
private val _uploadSuccessEvent = MutableSharedFlow<UploadTaskEntity>(extraBufferCapacity = 64)
|
||||
val uploadSuccessEvent: SharedFlow<UploadTaskEntity> = _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<Boolean>()
|
||||
|
||||
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<Boolean>()
|
||||
|
||||
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<NetworkResponse<AliyunOSSResponse>>? = 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<AliyunOSSResponse>
|
||||
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<UploadTaskEntity>) {
|
||||
if (tasks.isEmpty()) return
|
||||
|
||||
val toInsert = mutableListOf<UploadTaskEntity>()
|
||||
val toEnqueue = mutableListOf<UploadTaskEntity>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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<String, Any?> = emptyMap(),
|
||||
|
||||
@SerializedName("created_at")
|
||||
var created_at: String = "",
|
||||
|
||||
@SerializedName("updated_at")
|
||||
var updated_at: String = ""
|
||||
|
||||
|
||||
)
|
||||
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
|
||||
)
|
||||
@@ -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<Int>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<String>, // 已选择的海报
|
||||
@SerializedName("all_posters")
|
||||
val allPosters: List<String>, // 所有在显示列表的海报(posters + all_posters + 新上传的海报的并集)
|
||||
@SerializedName("bgm")
|
||||
val bgm: String, // 选择的提示音
|
||||
@SerializedName("all_bgm")
|
||||
val allBgm: List<String>, // 所有在显示列表的提示音(bgm + all_bgm + 新上传的提示音的并集)
|
||||
@SerializedName("service_phone")
|
||||
val servicePhone: String? = null, // 首页客服电话
|
||||
@SerializedName("is_show_QR_code")
|
||||
val isShowQRCode: Boolean? = null // 是否显示隐私取片二维码
|
||||
)
|
||||
|
||||
@@ -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<Int>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
)
|
||||
@@ -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<ImageInfo>?
|
||||
)
|
||||
|
||||
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?
|
||||
)
|
||||
|
||||
@@ -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<String>?, // 已选择的海报列表(字符串数组)
|
||||
@SerializedName("all_posters")
|
||||
val allPosters: List<String>?, // 所有可用的海报列表
|
||||
@SerializedName("bgm")
|
||||
val bgm: String?, // 已选择的提示音
|
||||
@SerializedName("all_bgm")
|
||||
val allBgm: List<String>?, // 所有可用的提示音列表
|
||||
@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?
|
||||
)
|
||||
|
||||
@@ -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<FaceSearchResult>?
|
||||
)
|
||||
|
||||
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?
|
||||
)
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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<MsgEntity> = emptyList(),
|
||||
|
||||
@SerializedName("is_refresh")
|
||||
var isRefresh : Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yzx.kiosk.network.model.response
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class NetworkResponse<T>(
|
||||
@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
|
||||
}
|
||||
|
||||
@@ -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<OscarLiveItem>? = 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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
)
|
||||
@@ -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<Boolean> = _isOnline.asStateFlow()
|
||||
|
||||
// 已用时间(秒)
|
||||
private val _elapsedTime = MutableStateFlow(0L)
|
||||
val elapsedTime: StateFlow<Long> = _elapsedTime.asStateFlow()
|
||||
|
||||
// 下次上报倒计时(秒)
|
||||
private val _nextReportCountdown = MutableStateFlow(0L)
|
||||
val nextReportCountdown: StateFlow<Long> = _nextReportCountdown.asStateFlow()
|
||||
|
||||
// 提醒设置(分钟)
|
||||
private val _reminderMinutes = MutableStateFlow(appStore.getLocationReminderMinutes())
|
||||
val reminderMinutes: StateFlow<Int> = _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
|
||||
}
|
||||
|
||||
@@ -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<NetworkResponse<Any>> = flow {
|
||||
// emit(netService.example())
|
||||
// }.flowOn(Dispatchers.IO)
|
||||
|
||||
fun pushUrlByAliyun(scenicid: String, url: String, folderId: Int): Flow<NetworkResponse<Any>> =
|
||||
flow {
|
||||
emit(netService.pushUrlByAliyun(scenicid, url, folderId.toString()))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun aliyunOSSBucket(bucket: String): Flow<NetworkResponse<AliyunOSSResponse>> = flow {
|
||||
emit(netService.aliyunOSSBucket(bucket))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun aliyunStsToken(bucket: String): Flow<NetworkResponse<AliyunOSSResponse>> = flow {
|
||||
emit(netService.aliyunStsToken(bucket))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun checkVersion(): Flow<NetworkResponse<VersionResponse>> = flow {
|
||||
emit(netService.checkApkVersion())
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun checkLatestVersion(type: Int = 8): Flow<NetworkResponse<Any>> = flow {
|
||||
emit(netService.checkLatestVersion(type))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getEquipmentConfig(): Flow<NetworkResponse<EquipmentConfigResponse>> = flow {
|
||||
emit(netService.getEquipmentConfig())
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getOscarLives(format: String = "rtmp"): Flow<NetworkResponse<OscarLivesData>> = flow {
|
||||
emit(netService.getOscarLives(format))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun setConfig(request: SetConfigRequest): Flow<NetworkResponse<Any>> = flow {
|
||||
emit(netService.setConfig(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getCodeUrl(): Flow<NetworkResponse<GetCodeUrlResponse>> = flow {
|
||||
emit(netService.getCodeUrl())
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getPrintInfo(): Flow<NetworkResponse<GetPrintInfoResponse>> = flow {
|
||||
emit(netService.getPrintInfo())
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun verifyResult(request: VerifyResultRequest): Flow<NetworkResponse<VerifyResultResponse>> = flow {
|
||||
emit(netService.verifyResult(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getPayUrl(request: GetPayUrlRequest): Flow<NetworkResponse<GetPayUrlResponse>> = flow {
|
||||
emit(netService.getPayUrl(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
|
||||
emit(netService.getSocketToken())
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun printNotify(request: PrintNotifyRequest): Flow<NetworkResponse<Any>> = flow {
|
||||
emit(netService.printNotify(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun printComplete(request: PrintCompleteRequest): Flow<NetworkResponse<Any>> = flow {
|
||||
emit(netService.printComplete(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun saveAlbumUrl(request: SaveAlbumUrlRequest): Flow<NetworkResponse<GetCodeUrlResponse>> = flow {
|
||||
emit(netService.saveAlbumUrl(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
@@ -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<List<UploadTaskEntity>>(emptyList())
|
||||
val tasksAddedEvent: StateFlow<List<UploadTaskEntity>> = _tasksAddedEvent.asStateFlow()
|
||||
|
||||
// 定义上传控制指令
|
||||
private val _startUploadCommands = MutableSharedFlow<String>() // 发送taskId
|
||||
val startUploadCommands: SharedFlow<String> = _startUploadCommands.asSharedFlow()
|
||||
|
||||
private val _cancelUploadCommands = MutableSharedFlow<String>()
|
||||
val cancelUploadCommands: SharedFlow<String> = _cancelUploadCommands.asSharedFlow()
|
||||
|
||||
private val _retryUploadCommands = MutableSharedFlow<String>()
|
||||
val retryUploadCommands: SharedFlow<String> = _retryUploadCommands.asSharedFlow()
|
||||
|
||||
// 带 replay 的完成事件
|
||||
private val _uploadCompletedEventTwo = MutableSharedFlow<String>(replay = 1)
|
||||
val uploadCompletedEventTwo = _uploadCompletedEventTwo.asSharedFlow()
|
||||
|
||||
// 兼容保留旧的事件
|
||||
private val _uploadCompletedEvent = MutableSharedFlow<String>()
|
||||
val uploadCompletedEvent = _uploadCompletedEvent.asSharedFlow()
|
||||
|
||||
|
||||
/**
|
||||
* 添加任务队列的操作方法
|
||||
* @param tasks 需要添加的任务列表
|
||||
*/
|
||||
suspend fun addTasksToQueue(tasks: List<UploadTaskEntity>) {
|
||||
_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<List<UploadTaskEntity>> {
|
||||
return localDataSource.observeAllUploadTasks()
|
||||
//return transferViewModel.uploadTasks
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据任务ID获取上传任务
|
||||
* @param taskId 任务ID
|
||||
* @return 上传任务对象
|
||||
*/
|
||||
suspend fun getUploadTaskById(taskId: String): UploadTaskEntity? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// 实现从数据库或内存中查找任务的逻辑
|
||||
// 这里需要根据你的实际实现来编写
|
||||
localDataSource.getUploadTaskById(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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<PushMessage>()
|
||||
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<String, String> = emptyMap(),
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
) : Serializable
|
||||
|
||||
// 检查应用是否在前台
|
||||
fun isAppInForeground(): Boolean {
|
||||
val appProcessInfo = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(appProcessInfo)
|
||||
return appProcessInfo.importance ==
|
||||
ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
|
||||
}
|
||||
}
|
||||
@@ -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 <T> handlePagingResult(
|
||||
call: suspend () -> Result<NetworkResponse<T>>,
|
||||
onSuccess: (T) -> PagingSource.LoadResult<Int, MsgEntity>,
|
||||
onError: (Throwable) -> PagingSource.LoadResult<Int, MsgEntity>
|
||||
): PagingSource.LoadResult<Int, MsgEntity> {
|
||||
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 <T> handlePagingResult2(
|
||||
call: suspend () -> Result<NetworkResponse<T>>,
|
||||
onSuccess: (T) -> PagingSource.LoadResult<Int, MsgEntity>,
|
||||
onError: (Throwable) -> PagingSource.LoadResult<Int, MsgEntity>
|
||||
): PagingSource.LoadResult<Int, MsgEntity> {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yzx.kiosk.network.result
|
||||
|
||||
sealed interface Result<out T> {
|
||||
|
||||
data object Loading : Result<Nothing>
|
||||
|
||||
data class Success<T>(val data: T) : Result<T>
|
||||
|
||||
data class Error(val exception: Throwable) : Result<Nothing>
|
||||
|
||||
data class Progress(val percent: Int) : Result<Nothing>
|
||||
}
|
||||
@@ -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 <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
|
||||
.onStart { emit(Result.Loading) }
|
||||
.catch { emit(Result.Error(it)) }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user