first commit

This commit is contained in:
2026-08-04 13:55:20 +08:00
commit 3362790bfd
513 changed files with 30427 additions and 0 deletions
+123
View File
@@ -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
)
}
}
}
}
}
}
@@ -0,0 +1,77 @@
package com.yzx.kiosk.ui.common.dialog
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.yzx.kiosk.component.loading.LottieLoading
import com.yzx.kiosk.theme.TextColor666
@Composable
fun ProgressLoadingView(
progress: Int,
isLoading: Boolean = false,
content: @Composable () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
content()
if (isLoading) {
BackHandler(enabled = true) {
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.2f))
.clickable(
enabled = true,
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {},
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.width(100.dp)
.clip(RoundedCornerShape(15.dp))
.background(Color.White)
.padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LottieLoading()
Text(
text = "上传中${progress}%",
fontSize = 12.sp,
color = TextColor666,
)
}
}
}
}
}
@Preview
@Composable
fun ProgressLoadingViewPreview() {
ProgressLoadingView(progress = 40, isLoading = true) {
}
}

Some files were not shown because too many files have changed in this diff Show More