first commit
This commit is contained in:
@@ -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)) }
|
||||
@@ -0,0 +1,101 @@
|
||||
@file:Suppress("UNCHECKED_CAST")
|
||||
|
||||
package com.yzx.kiosk.network.result
|
||||
|
||||
import android.util.Log
|
||||
import com.hjq.toast.Toaster
|
||||
import com.yzx.kiosk.network.model.response.NetworkResponse
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object ResultHandler {
|
||||
|
||||
private fun <T> handleResult(
|
||||
scope: CoroutineScope,
|
||||
flow: Flow<Result<NetworkResponse<T>>>,
|
||||
showToast: Boolean = true,
|
||||
onLoading: () -> Unit = {},
|
||||
onSuccess: (NetworkResponse<T>) -> Unit = {},
|
||||
onSuccessWithData: (T) -> Unit = {},
|
||||
onError: (String, Throwable?) -> Unit = { _, _ -> },
|
||||
onEnd: () -> Unit = {},
|
||||
onTokenFailed: () -> Unit = {},
|
||||
onProgress: (Int) -> Unit = {},
|
||||
) {
|
||||
scope.launch {
|
||||
flow.collectLatest { result ->
|
||||
when (result) {
|
||||
is Result.Loading -> {
|
||||
onLoading()
|
||||
}
|
||||
|
||||
is Result.Progress -> {
|
||||
onProgress(result.percent)
|
||||
}
|
||||
|
||||
is Result.Success -> {
|
||||
val response = result.data
|
||||
onSuccess(response)
|
||||
|
||||
if (response.isSucceeded) {
|
||||
onSuccessWithData(response.data ?: Unit as T)
|
||||
} else if (response.isTokenFailed) {
|
||||
onTokenFailed()
|
||||
} else {
|
||||
val errorMsg = response.message
|
||||
?: "请求失败,请重试!"
|
||||
onError(errorMsg, Exception(errorMsg))
|
||||
|
||||
if (showToast) {
|
||||
Toaster.show(errorMsg)
|
||||
}
|
||||
}
|
||||
onEnd()
|
||||
}
|
||||
|
||||
is Result.Error -> {
|
||||
val errorMsg =
|
||||
result.exception.message
|
||||
?: "请求失败,请重试!"
|
||||
onError(errorMsg, result.exception)
|
||||
|
||||
if (showToast) {
|
||||
Log.i("ResultHandler", errorMsg)
|
||||
//Toaster.show(errorMsg) 20250721
|
||||
}
|
||||
|
||||
onEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> handleResultWithData(
|
||||
scope: CoroutineScope,
|
||||
flow: Flow<Result<NetworkResponse<T>>>,
|
||||
showToast: Boolean = true,
|
||||
onLoading: () -> Unit = {},
|
||||
onDataResponse: (NetworkResponse<T>) -> Unit = {},
|
||||
onData: (T) -> Unit,
|
||||
onError: (String, Throwable?) -> Unit = { _, _ -> },
|
||||
onEnd: () -> Unit = {},
|
||||
onTokenFailed: () -> Unit = {},
|
||||
onProgress: (Int) -> Unit = {},
|
||||
) {
|
||||
handleResult(
|
||||
scope = scope,
|
||||
flow = flow,
|
||||
showToast = showToast,
|
||||
onLoading = onLoading,
|
||||
onSuccessWithData = onData,
|
||||
onSuccess = onDataResponse,
|
||||
onError = onError,
|
||||
onEnd = onEnd,
|
||||
onTokenFailed = onTokenFailed,
|
||||
onProgress = onProgress,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface FaceSearchService {
|
||||
@Multipart
|
||||
@POST("/{sn}/api/search")
|
||||
suspend fun searchFace(
|
||||
@Path("sn") sn: String,
|
||||
@Header("Authorization") authorization: String,
|
||||
@Part image: MultipartBody.Part,
|
||||
@Part("threshold") threshold: RequestBody?,
|
||||
@Part("index_date") indexDate: RequestBody?
|
||||
): Response<FaceSearchResponse>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.SetConfigRequest
|
||||
import com.yzx.kiosk.network.model.request.VerifyResultRequest
|
||||
import com.yzx.kiosk.network.model.response.AliyunOSSResponse
|
||||
import com.yzx.kiosk.network.model.response.EquipmentConfigResponse
|
||||
import com.yzx.kiosk.network.model.response.GetCodeUrlResponse
|
||||
import com.yzx.kiosk.network.model.response.GetPayUrlResponse
|
||||
import com.yzx.kiosk.network.model.response.GetPrintInfoResponse
|
||||
import com.yzx.kiosk.network.model.response.NetworkResponse
|
||||
import com.yzx.kiosk.network.model.response.OscarLivesData
|
||||
import com.yzx.kiosk.network.model.response.SocketTokenResponse
|
||||
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
||||
import com.yzx.kiosk.network.model.response.VersionResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface NetworkService {
|
||||
// 基础接口示例,可根据需要添加
|
||||
// @POST("/api/example")
|
||||
// suspend fun example(@Body req: Any): NetworkResponse<Any>
|
||||
@GET("/api/oscar/config/get-sts-token")
|
||||
suspend fun aliyunOSSBucket(
|
||||
@Query("bucket") bucket: String,
|
||||
): NetworkResponse<AliyunOSSResponse>
|
||||
|
||||
@GET("/api/oscar/config/get-sts-token")
|
||||
suspend fun aliyunStsToken(
|
||||
@Query("bucket") bucket: String,
|
||||
): NetworkResponse<AliyunOSSResponse>
|
||||
|
||||
@POST("/api/yf-handset-app/photog/album/file-upload-url")
|
||||
suspend fun pushUrlByAliyun(
|
||||
@Query("scenic_id") scenicId: String,
|
||||
@Query("file_url") url: String? = null,
|
||||
@Query("folder_id") folderId: String? = null
|
||||
): NetworkResponse<Any>
|
||||
|
||||
@GET("/api/app/latest-version")
|
||||
suspend fun checkApkVersion(): NetworkResponse<VersionResponse>
|
||||
|
||||
@GET("/api/version/latest")
|
||||
suspend fun checkLatestVersion(@Query("type") type: Int): NetworkResponse<Any>
|
||||
|
||||
@GET("/api/oscar/config/get")
|
||||
suspend fun getEquipmentConfig(): NetworkResponse<EquipmentConfigResponse>
|
||||
|
||||
@GET("/api/oscar/lives")
|
||||
suspend fun getOscarLives(@Query("format") format: String): NetworkResponse<OscarLivesData>
|
||||
|
||||
@POST("/api/oscar/config/set")
|
||||
suspend fun setConfig(@Body request: SetConfigRequest): NetworkResponse<Any>
|
||||
|
||||
@GET("/api/oscar/photo/get-code-url")
|
||||
suspend fun getCodeUrl(): NetworkResponse<GetCodeUrlResponse>
|
||||
|
||||
@GET("/api/oscar/photo/get-print-info")
|
||||
suspend fun getPrintInfo(): NetworkResponse<GetPrintInfoResponse>
|
||||
|
||||
@POST("/api/oscar/order/verify-result")
|
||||
suspend fun verifyResult(@Body request: VerifyResultRequest): NetworkResponse<VerifyResultResponse>
|
||||
|
||||
@POST("/api/oscar/order/get-pay-url")
|
||||
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
|
||||
|
||||
@GET("/api/oscar/socket-token")
|
||||
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
|
||||
|
||||
@POST("/api/oscar/order/print-notify")
|
||||
suspend fun printNotify(@Body request: PrintNotifyRequest): NetworkResponse<Any>
|
||||
|
||||
@POST("/api/oscar/order/print-complete")
|
||||
suspend fun printComplete(@Body request: PrintCompleteRequest): NetworkResponse<Any>
|
||||
|
||||
@POST("/api/oscar/order/save-album-url")
|
||||
suspend fun saveAlbumUrl(@Body request: SaveAlbumUrlRequest): NetworkResponse<GetCodeUrlResponse>
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import com.yzx.kiosk.network.model.response.NetworkResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
|
||||
interface UploadService {
|
||||
@Multipart
|
||||
@POST("/api/yf-handset-app/photog/userinfo-update-avatar")
|
||||
suspend fun uploadPhotographerAvatar(@Part file: MultipartBody.Part): NetworkResponse<Any>
|
||||
|
||||
@Multipart
|
||||
@POST("/api/yf-handset-app/photog/album/file-upload")
|
||||
suspend fun uploadPhotographerAlbumFile(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("scenic_id") scenicId: RequestBody,
|
||||
@Part("folder_id") folderId: RequestBody,
|
||||
): NetworkResponse<Any>
|
||||
|
||||
@Multipart
|
||||
@POST("/api/yf-handset-app/photog/album/file-upload")
|
||||
fun uploadPhotographerAlbumFileCall(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("scenic_id") scenicId: RequestBody,
|
||||
@Part("folder_id") folderId: RequestBody,
|
||||
): Call<NetworkResponse<Any>>
|
||||
|
||||
|
||||
@Multipart
|
||||
@POST("/api/yf-handset-app/photog/album/file-upload")
|
||||
fun uploadPhotographerMaterialFileCall(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("scenic_id") scenicId: RequestBody,
|
||||
@Part("folder_id") folderId: RequestBody,
|
||||
): Call<NetworkResponse<Any>>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yzx.kiosk.priter
|
||||
|
||||
enum class PRINTSIZE(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val desc: String
|
||||
) {
|
||||
RX1(1840, 1240, "RX1 宽度 1840, 高度 1240"),
|
||||
QW410(1266, 1836, "QW410 宽度 1266, 高度 1836"),
|
||||
SEAORY(1012, 648, "卡片打印机 宽度 1012, 高度 648")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yzx.kiosk.priter
|
||||
|
||||
/**
|
||||
* 打印记录类
|
||||
*/
|
||||
data class PrintRecord(
|
||||
val fileName: String, // 文件名
|
||||
val printEndTime: Long, // 打印结束时间(时间戳)
|
||||
val isSuccess: Boolean, // 打印成功或失败的标记
|
||||
val message: String = "" // 附加信息(如失败原因)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.yzx.kiosk.priter
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 打印状态管理器
|
||||
* 用于管理全局打印状态,供 WebSocket 心跳包使用
|
||||
*
|
||||
* 注意:打印机在线状态和打印状态分开管理,避免混用
|
||||
*/
|
||||
@Singleton
|
||||
class PrintStatusManager @Inject constructor() {
|
||||
companion object {
|
||||
// 打印状态枚举(用于心跳包)
|
||||
const val STATUS_IDLE = 1 // 可以接受打印任务(在线且空闲)
|
||||
const val STATUS_PRINTING = 2 // 正在打印,无法接受打印任务(在线且打印中)
|
||||
const val STATUS_OFFLINE = 3 // 打印机离线,无法接受打印任务
|
||||
}
|
||||
|
||||
// 打印状态:空闲或正在打印(仅当在线时有效)
|
||||
private val _printStatus = MutableStateFlow(STATUS_IDLE)
|
||||
val printStatus: StateFlow<Int> = _printStatus.asStateFlow()
|
||||
|
||||
// 打印机在线状态:单独管理,避免与打印状态混用
|
||||
private val _isOnline = MutableStateFlow(true) // 默认假设在线
|
||||
val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
|
||||
|
||||
/**
|
||||
* 设置打印状态(仅当在线时有效)
|
||||
* @param status STATUS_IDLE 或 STATUS_PRINTING
|
||||
*/
|
||||
fun setPrintStatus(status: Int) {
|
||||
if (status == STATUS_IDLE || status == STATUS_PRINTING) {
|
||||
_printStatus.value = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前打印状态(用于心跳包)
|
||||
* 返回值:
|
||||
* - 1: 可以接受打印任务(在线且空闲)
|
||||
* - 2: 正在打印,无法接受打印任务(在线且打印中)
|
||||
* - 3: 打印机离线,无法接受打印任务
|
||||
*/
|
||||
fun getPrintStatus(): Int {
|
||||
// 如果离线,直接返回离线状态
|
||||
if (!_isOnline.value) {
|
||||
return STATUS_OFFLINE
|
||||
}
|
||||
// 如果在线,返回当前打印状态(空闲或打印中)
|
||||
return _printStatus.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为正在打印(仅当在线时有效)
|
||||
*/
|
||||
fun setPrinting() {
|
||||
if (_isOnline.value) {
|
||||
_printStatus.value = STATUS_PRINTING
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为空闲(可以接受打印任务,仅当在线时有效)
|
||||
*/
|
||||
fun setIdle() {
|
||||
if (_isOnline.value) {
|
||||
_printStatus.value = STATUS_IDLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置打印机在线状态
|
||||
* @param online true-在线, false-离线
|
||||
*/
|
||||
fun setOnline(online: Boolean) {
|
||||
_isOnline.value = online
|
||||
if (online) {
|
||||
// 上线时,默认设置为空闲状态
|
||||
_printStatus.value = STATUS_IDLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置为离线(兼容旧代码)
|
||||
*/
|
||||
fun setOffline() {
|
||||
setOnline(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查打印机是否在线
|
||||
*/
|
||||
fun isOnline(): Boolean {
|
||||
return _isOnline.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查打印机是否离线
|
||||
*/
|
||||
fun isOffline(): Boolean {
|
||||
return !_isOnline.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yzx.kiosk.priter
|
||||
|
||||
import android.content.Context
|
||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PrinterModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePrinterService(
|
||||
@ApplicationContext context: Context,
|
||||
appStoreDataSource: AppStoreDataSource,
|
||||
printStatusManager: PrintStatusManager
|
||||
): PrinterService {
|
||||
return PrinterService(context, appStoreDataSource, printStatusManager)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.yzx.kiosk.priter
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import com.yzx.kiosk.utils.ToastUtils
|
||||
import jp.co.dnp.photoprintlib.DNPPhotoPrint
|
||||
import jp.co.dnpLib.BmpUtil
|
||||
import jp.co.dnpLib.common.AndroidBmpUtil
|
||||
import jp.co.dnpLib.common.ECutterMode
|
||||
import jp.co.dnpLib.common.EMediaSize
|
||||
import jp.co.dnpLib.common.EOverCoat
|
||||
import jp.co.dnpLib.common.EResolution
|
||||
import jp.co.dnpLib.print.PrintJob
|
||||
import jp.co.dnpLib.print.PrintJobStatus
|
||||
import jp.co.dnpLib.print.PrintManager
|
||||
import jp.co.dnpLib.print.PrintQueue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* 打印服务类
|
||||
* 负责打印机初始化和打印任务管理
|
||||
*/
|
||||
@Singleton
|
||||
class PrinterService(
|
||||
private val context: Context,
|
||||
private val appStoreDataSource: AppStoreDataSource,
|
||||
private val printStatusManager: PrintStatusManager
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "PrinterService"
|
||||
}
|
||||
|
||||
private var mDnpPhotoPrint: DNPPhotoPrint? = null
|
||||
|
||||
/**
|
||||
* 初始化打印机(耗时操作,需在IO线程调用)
|
||||
* @return 是否初始化成功
|
||||
*/
|
||||
suspend fun initDnpPrint(): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
mDnpPhotoPrint = App.getDnpPhotoPrint()
|
||||
|
||||
// 定义打印机端口数组
|
||||
val printers = Array<IntArray?>(10) { IntArray(2) }
|
||||
LogUtils.d("printer", printers.toString())
|
||||
|
||||
// 获取打印机端口信息
|
||||
val portNum: Int? = mDnpPhotoPrint?.GetPrinterPortNum(printers)
|
||||
LogUtils.d("printer-portNum", "端口 printers = " + printers.contentDeepToString())
|
||||
|
||||
// 保存第一个打印机ID
|
||||
val printId = printers[0]!![0]
|
||||
appStoreDataSource.savePrintId(printId)
|
||||
// 初始化成功,设置为在线且空闲状态
|
||||
printStatusManager.setOnline(true)
|
||||
printStatusManager.setIdle()
|
||||
LogUtils.d(TAG, "打印机初始化成功,打印机ID: $printId")
|
||||
ToastUtils.show("打印机连接成功")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "打印机初始化失败: ${e.message}")
|
||||
// 初始化失败,设置为离线状态
|
||||
printStatusManager.setOnline(false)
|
||||
ToastUtils.show("打印机连接失败: ${e.message ?: "未知错误"}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前打印机ID
|
||||
*/
|
||||
fun getPrintId(): Int {
|
||||
return appStoreDataSource.getPrintId()
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备打印图片:保持原比例,全内容居中,空白区域背景为白色,宽图可调整方向
|
||||
* @param originalBitmap 原始图片(不会被回收)
|
||||
* @param printSize 打印尺寸
|
||||
* @param printId 打印机ID
|
||||
* @return 处理后的图片
|
||||
*/
|
||||
private fun preparePrintBitmap(originalBitmap: Bitmap, printSize: PRINTSIZE, printId: Int): Bitmap {
|
||||
var workingBitmap: Bitmap? = null // 用于处理的临时bitmap
|
||||
var rotatedBitmap: Bitmap? = null // 旋转后的bitmap(如果需要)
|
||||
var scaledBitmap: Bitmap? = null // 缩放后的bitmap
|
||||
|
||||
try {
|
||||
val originalWidth = originalBitmap.width
|
||||
val originalHeight = originalBitmap.height
|
||||
val isWideImage = originalWidth > originalHeight // 是否为宽图
|
||||
|
||||
LogUtils.d(TAG, "原始图片: ${originalWidth}x${originalHeight}, 是否宽图: $isWideImage")
|
||||
|
||||
// 判断是否需要旋转
|
||||
// RX1打印机需要横向图片(1840x1240),QW410需要纵向图片(1266x1836)
|
||||
val needRotate = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||
// RX1打印机:如果图片是纵向的,需要旋转90度
|
||||
!isWideImage
|
||||
} else {
|
||||
// QW410打印机:如果图片是横向的,需要旋转90度
|
||||
isWideImage
|
||||
}
|
||||
|
||||
// 如果需要旋转,先旋转图片
|
||||
if (needRotate) {
|
||||
LogUtils.d(TAG, "旋转图片90度以适应打印方向")
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(90f)
|
||||
rotatedBitmap = Bitmap.createBitmap(
|
||||
originalBitmap, 0, 0,
|
||||
originalBitmap.width, originalBitmap.height, matrix, true
|
||||
)
|
||||
workingBitmap = rotatedBitmap
|
||||
LogUtils.d(TAG, "旋转后图片尺寸: ${workingBitmap.width}x${workingBitmap.height}")
|
||||
} else {
|
||||
workingBitmap = originalBitmap
|
||||
}
|
||||
|
||||
// 计算缩放比例,保持原比例
|
||||
val scale = min(
|
||||
printSize.width.toFloat() / workingBitmap.width,
|
||||
printSize.height.toFloat() / workingBitmap.height
|
||||
)
|
||||
|
||||
val scaledWidth = (workingBitmap.width * scale).toInt()
|
||||
val scaledHeight = (workingBitmap.height * scale).toInt()
|
||||
|
||||
LogUtils.d(TAG, "缩放比例: $scale, 缩放后尺寸: ${scaledWidth}x${scaledHeight}")
|
||||
|
||||
// 创建缩放后的图片
|
||||
scaledBitmap = Bitmap.createScaledBitmap(workingBitmap, scaledWidth, scaledHeight, true)
|
||||
|
||||
// 创建白色背景的画布
|
||||
val outputBitmap = Bitmap.createBitmap(
|
||||
printSize.width,
|
||||
printSize.height,
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = Canvas(outputBitmap)
|
||||
|
||||
// 填充白色背景
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
// 计算居中位置
|
||||
val left = (printSize.width - scaledWidth) / 2f
|
||||
val top = (printSize.height - scaledHeight) / 2f
|
||||
|
||||
LogUtils.d(TAG, "居中位置: left=$left, top=$top")
|
||||
|
||||
// 在画布上绘制缩放后的图片(居中)
|
||||
canvas.drawBitmap(scaledBitmap, left, top, null)
|
||||
|
||||
// 回收临时bitmap
|
||||
scaledBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
rotatedBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
|
||||
return outputBitmap
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "准备打印图片失败: ${e.message}")
|
||||
// 如果出错,回收可能创建的临时bitmap
|
||||
scaledBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
rotatedBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印图片(耗时操作)
|
||||
* @param originalBitmap 要打印的原始图片
|
||||
* @param callback 打印结果回调
|
||||
*/
|
||||
suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = withContext(Dispatchers.IO) {
|
||||
if (originalBitmap == null) {
|
||||
LogUtils.e(TAG, "获取图片失败")
|
||||
callback.onPrintResult(false,"获取图片失败")
|
||||
return@withContext
|
||||
}
|
||||
LogUtils.e(TAG, "获取图片成功")
|
||||
|
||||
// 在开始打印前,检查打印机是否在线
|
||||
if (printStatusManager.isOffline()) {
|
||||
LogUtils.e(TAG, "打印机离线,无法打印")
|
||||
callback.onPrintResult(false, "打印机离线,无法打印")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// 设置打印状态为正在打印
|
||||
printStatusManager.setPrinting()
|
||||
|
||||
try {
|
||||
val printQueue = PrintQueue(context)
|
||||
val printId = appStoreDataSource.getPrintId()
|
||||
|
||||
// 再次检查打印机ID是否有效(如果打印机未初始化,printId 可能为 0)
|
||||
if (printId == 0) {
|
||||
LogUtils.e(TAG, "打印机未初始化,printId=0")
|
||||
printStatusManager.setOffline()
|
||||
callback.onPrintResult(false, "打印机未初始化")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
LogUtils.d(TAG, "开始打印,打印机ID: $printId")
|
||||
LogUtils.d(TAG, "原始图片尺寸: ${originalBitmap.width} x ${originalBitmap.height}")
|
||||
|
||||
// 获取打印尺寸
|
||||
val printSize = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||
PRINTSIZE.RX1
|
||||
} else {
|
||||
PRINTSIZE.QW410
|
||||
}
|
||||
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
|
||||
|
||||
// 准备打印图片(保持原比例,居中,白色背景)
|
||||
val outputBitmap: Bitmap
|
||||
val outFile = File(
|
||||
context.getExternalFilesDir(null),
|
||||
"ProcessedPhotos/${System.currentTimeMillis()}_print.bmp"
|
||||
)
|
||||
outFile.parentFile?.mkdirs()
|
||||
|
||||
try {
|
||||
outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId)
|
||||
|
||||
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||
BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue)
|
||||
} else {
|
||||
AndroidBmpUtil.save(
|
||||
outputBitmap,
|
||||
outFile.absolutePath,
|
||||
PRINTSIZE.QW410.width,
|
||||
PRINTSIZE.QW410.height
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
callback.onPrintResult(false,"图片处理失败: ${e.message}")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// 创建打印任务
|
||||
val job = PrintJob(
|
||||
outFile.absolutePath,
|
||||
1,
|
||||
EResolution.RESO300,
|
||||
if (printId != PrintManager.EPrinter.QW410_DEF.id) EMediaSize.CSP_PC else EMediaSize.CSP_4x6,
|
||||
ECutterMode.MODE_STANDARD,
|
||||
EOverCoat.GLOSSY,
|
||||
PrintJobStatus.PROCESSING,
|
||||
"打印中",
|
||||
1
|
||||
)
|
||||
printQueue.addJob(job)
|
||||
|
||||
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||
// 打印完成,设置为空闲
|
||||
printStatusManager.setIdle()
|
||||
// 回收 outputBitmap
|
||||
try {
|
||||
if (!outputBitmap.isRecycled) {
|
||||
outputBitmap.recycle()
|
||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
LogUtils.e(TAG, "回收 outputBitmap 异常: ${ex.message}")
|
||||
}
|
||||
callback.onPrintResult(true)
|
||||
} else {
|
||||
// QW410打印机需要延迟回调
|
||||
delay(18000)
|
||||
LogUtils.e(TAG, "打印成功回调了-----------------------------")
|
||||
// 打印完成,设置为空闲
|
||||
printStatusManager.setIdle()
|
||||
// 回收 outputBitmap
|
||||
try {
|
||||
if (!outputBitmap.isRecycled) {
|
||||
outputBitmap.recycle()
|
||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
LogUtils.e(TAG, "回收 outputBitmap 异常: ${ex.message}")
|
||||
}
|
||||
callback.onPrintResult(true)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "打印失败: ${e.message}")
|
||||
// 打印失败,设置为空闲
|
||||
printStatusManager.setIdle()
|
||||
callback.onPrintResult(false,"打印失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印结果回调接口
|
||||
*/
|
||||
interface PrintCallback {
|
||||
fun onPrintResult(isSuccess: Boolean,msg:String = "")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.yzx.kiosk.receiver
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.yzx.kiosk.SplashActivity
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
|
||||
/**
|
||||
* 开机自启动广播接收器
|
||||
* 接收系统开机完成广播,自动启动应用
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
try {
|
||||
val action = intent.action
|
||||
LogUtils.d("BootReceiver", "收到广播: $action, package: ${context.packageName}")
|
||||
|
||||
// 检查是否是开机完成广播
|
||||
// 注意:ACTION_MY_PACKAGE_REPLACED 和 ACTION_PACKAGE_REPLACED 应该由 VersionUpdateManager 处理
|
||||
// 这里只处理真正的开机完成广播,避免与应用更新重启冲突
|
||||
if (Intent.ACTION_BOOT_COMPLETED == action ||
|
||||
Intent.ACTION_LOCKED_BOOT_COMPLETED == action) {
|
||||
|
||||
LogUtils.d("BootReceiver", "检测到开机完成,准备启动应用")
|
||||
|
||||
// 使用 goAsync() 保持 receiver 活跃,避免在 Handler 执行前被销毁
|
||||
val pendingResult = goAsync()
|
||||
|
||||
// 延迟启动,确保系统完全启动
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
try {
|
||||
// 检查应用是否已经在前台运行
|
||||
if (isAppInForeground(context)) {
|
||||
LogUtils.d("BootReceiver", "应用已在前台运行,无需启动")
|
||||
} else {
|
||||
startApp(context)
|
||||
LogUtils.d("BootReceiver", "应用启动完成")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("BootReceiver", "启动应用异常: ${e.message}")
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
// 完成异步操作
|
||||
pendingResult.finish()
|
||||
}
|
||||
}, 2000) // 延迟2秒启动,确保系统服务已就绪
|
||||
} else if (Intent.ACTION_MY_PACKAGE_REPLACED == action ||
|
||||
(Intent.ACTION_PACKAGE_REPLACED == action &&
|
||||
intent.dataString?.contains(context.packageName) == true)) {
|
||||
// 应用更新广播,由 VersionUpdateManager 处理,这里只记录日志
|
||||
LogUtils.d("BootReceiver", "检测到应用更新广播,但由 VersionUpdateManager 处理,不执行启动")
|
||||
} else {
|
||||
LogUtils.d("BootReceiver", "广播不匹配,忽略: $action")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("BootReceiver", "处理开机广播异常: ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查应用是否在前台运行
|
||||
*/
|
||||
private fun isAppInForeground(context: Context): Boolean {
|
||||
return try {
|
||||
val appProcessInfo = ActivityManager.RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(appProcessInfo)
|
||||
val isForeground = appProcessInfo.importance ==
|
||||
ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
|
||||
LogUtils.d("BootReceiver", "应用前台状态检查: $isForeground (importance: ${appProcessInfo.importance})")
|
||||
isForeground
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("BootReceiver", "检查应用前台状态失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
// 如果检查失败,默认返回 false,允许启动应用
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动应用
|
||||
*/
|
||||
private fun startApp(context: Context) {
|
||||
try {
|
||||
val intent = Intent(context, SplashActivity::class.java).apply {
|
||||
// 添加标志,确保在新任务栈中启动
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
// 对于 Android 8.0 及以上版本,需要显式设置组件
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
LogUtils.d("BootReceiver", "应用启动成功 - SplashActivity")
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("BootReceiver", "启动应用失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yzx.kiosk.receiver;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.util.Log;
|
||||
|
||||
import com.yzx.kiosk.SplashActivity;
|
||||
|
||||
|
||||
public class BootReceiver2 extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (intent == null) return;
|
||||
String action = intent.getAction();
|
||||
Log.e("BootReceiver", "接收到广播:" + action);
|
||||
if (Intent.ACTION_BOOT_COMPLETED.equals(action)
|
||||
|| Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)) {
|
||||
Log.e("BootReceiver", "系统启动完成,准备启动主界面");
|
||||
Intent startIntent = new Intent(context, SplashActivity.class);
|
||||
startIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(startIntent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yzx.kiosk.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = LightColorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Primary,
|
||||
secondary = ColorPurple,
|
||||
tertiary = ColorSuccess,
|
||||
background = BackgroundColor,
|
||||
surface = BgWhiteLight,
|
||||
error = ColorDanger,
|
||||
onPrimary = TextWhite,
|
||||
onSecondary = TextWhite,
|
||||
onTertiary = TextWhite,
|
||||
onBackground = TextPrimaryLight,
|
||||
onSurface = TextPrimaryLight,
|
||||
onError = TextWhite,
|
||||
outline = BorderLight,
|
||||
surfaceVariant = BgContentLight,
|
||||
surfaceTint = Primary,
|
||||
surfaceContainer = BgWhiteLight,
|
||||
surfaceContainerHigh = BgWhiteLight,
|
||||
surfaceContainerHighest = BgWhiteLight
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yzx.kiosk.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Primary = Color(0xFF0073FF)
|
||||
|
||||
val ColorDanger = Color(0xFFFF2B2B)
|
||||
|
||||
val ColorPurple = Color(0xFF6831FF)
|
||||
|
||||
val ColorSuccess = Color(0xFF09BE4F)
|
||||
|
||||
val TextPrimaryLight = Color(0xFF181818)
|
||||
|
||||
val TextWhite = Color(0xFFFFFFFF)
|
||||
|
||||
val BgGreyLight = Color(0xFFF9FAFB)
|
||||
|
||||
val BgWhiteLight = Color(0xFFFFFFFF)
|
||||
|
||||
val BgContentLight = Color(0xFFF8F8F8)
|
||||
|
||||
val BorderLight = Color(0xFFEEEEEE)
|
||||
|
||||
val TextColor333 = Color(0xFF333333)
|
||||
|
||||
val TextColor666 = Color(0xFF666666)
|
||||
|
||||
val TextColor563 = Color(0xFF4B5563)
|
||||
|
||||
val TextColorBlack= Color(0xFF2E3746)
|
||||
|
||||
val TextColor999 = Color(0xFF999999)
|
||||
|
||||
val ColorTabGray = Color(0xFF7B8EAA)
|
||||
|
||||
val BackgroundColor = Color(0xFFF4F4F4)
|
||||
|
||||
val LocationgroundColor = Color(0xFFB2D5FF)
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.yzx.kiosk.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val TITLE_BAR_HEIGHT = 96.dp
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.yzx.kiosk.ui.common.dialog
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.commandiron.wheel_picker_compose.WheelDatePicker
|
||||
import com.yzx.kiosk.component.button.AppButton
|
||||
import java.time.LocalDate
|
||||
|
||||
@Composable
|
||||
fun DatePickDialog(
|
||||
onConfirm: (LocalDate) -> Unit = { _ -> },
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
|
||||
var snappedDate: LocalDate = LocalDate.now()
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = true
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color.White, RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 15.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
||||
WheelDatePicker(
|
||||
startDate = LocalDate.now(),
|
||||
) { date ->
|
||||
snappedDate = date
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
AppButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(45.dp),
|
||||
text = "确认",
|
||||
textSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
onClick = {
|
||||
onConfirm(snappedDate)
|
||||
onDismiss()
|
||||
},
|
||||
enable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DatePickDialogPreview() {
|
||||
DatePickDialog(
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.yzx.kiosk.ui.common.dialog
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
/** 剩余张数 ≤ 该值(含)时提示补充相纸 */
|
||||
object PrintPaperWarning {
|
||||
const val LOW_SHEETS_THRESHOLD: Int = 20
|
||||
}
|
||||
|
||||
fun shouldWarnLowPrintPaper(remainingSheets: Int): Boolean =
|
||||
remainingSheets <= PrintPaperWarning.LOW_SHEETS_THRESHOLD
|
||||
|
||||
/**
|
||||
* 相纸余量不足温馨提示(与首页客服电话同源展示号码)
|
||||
*/
|
||||
@Composable
|
||||
fun PaperLowWarningDialog(
|
||||
remainingSheets: Int,
|
||||
servicePhone: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val phoneDisplay = servicePhone.ifBlank { "--" }
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "温馨提示",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF000000),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append("当前打印相纸剩余 ")
|
||||
withStyle(SpanStyle(color = Color(0xFFE53935), fontWeight = FontWeight.Medium)) {
|
||||
append(remainingSheets.toString())
|
||||
}
|
||||
append(" 张,建议酌情打印。请联系工作人员及时补充相纸,联系电话:")
|
||||
append(phoneDisplay)
|
||||
append("。")
|
||||
},
|
||||
fontSize = 16.sp,
|
||||
color = Color(0xFF333333),
|
||||
lineHeight = 24.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFE8F4FC),
|
||||
contentColor = Color(0xFF2196F3)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "我知道了",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.yzx.kiosk.ui.common.dialog
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.yzx.kiosk.theme.TextColor333
|
||||
|
||||
@Composable
|
||||
fun PaperNumInputDialog(
|
||||
initialValue: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
title: String = "设置打印纸张数"
|
||||
) {
|
||||
var paperNum by remember(initialValue) { mutableStateOf(initialValue.toString()) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.White
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 标题
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
color = TextColor333,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
// 纸张数输入框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
BasicTextField(
|
||||
value = paperNum,
|
||||
onValueChange = { newValue ->
|
||||
// 只允许数字输入
|
||||
if (newValue.isEmpty() || (newValue.all { it.isDigit() } && newValue.toIntOrNull() != null && newValue.toInt() >= 0)) {
|
||||
paperNum = newValue
|
||||
errorMessage = null
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = TextColor333,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 取消按钮
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFF5F5F5)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "取消",
|
||||
color = TextColor333,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
// 确认按钮
|
||||
Button(
|
||||
onClick = {
|
||||
val num = paperNum.toIntOrNull()
|
||||
if (num == null || num < 0) {
|
||||
errorMessage = "请输入有效的纸张数"
|
||||
} else {
|
||||
onConfirm(num)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "确认",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.yzx.kiosk.ui.common.dialog
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.yzx.kiosk.theme.TextColor333
|
||||
|
||||
@Composable
|
||||
fun PasswordInputDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
title: String = "请输入密码"
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color.White
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// 标题
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
color = TextColor333,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
// 密码输入框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.background(Color(0xFFF5F5F5), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
BasicTextField(
|
||||
value = password,
|
||||
onValueChange = { newValue ->
|
||||
// 只允许数字输入
|
||||
if (newValue.all { it.isDigit() }) {
|
||||
password = newValue
|
||||
errorMessage = null
|
||||
}
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = TextColor333,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// 按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// 取消按钮
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFF5F5F5)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "取消",
|
||||
color = TextColor333,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
// 确认按钮
|
||||
Button(
|
||||
onClick = {
|
||||
if (password.isEmpty()) {
|
||||
errorMessage = "请输入密码"
|
||||
} else {
|
||||
onConfirm(password)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "确认",
|
||||
color = Color.White,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user