فهرست منبع

Merge branch 'feature/Launcher' into develop

cc12458 9 ماه پیش
والد
کامیت
163b174d94

+ 1 - 0
app/build.gradle.kts

@@ -134,6 +134,7 @@ dependencies {
   implementation(libs.androidx.ui.graphics)
   implementation(libs.androidx.ui.tooling.preview)
   implementation(libs.androidx.material3)
+  implementation(libs.androidx.material.icons.extended)
   testImplementation(libs.junit)
   androidTestImplementation(libs.androidx.junit)
   androidTestImplementation(libs.androidx.espresso.core)

+ 36 - 0
app/src/main/AndroidManifest.xml

@@ -21,6 +21,42 @@
 
         <category android:name="android.intent.category.LAUNCHER" />
       </intent-filter>
+
+      <intent-filter>
+        <action android:name="android.intent.action.VIEW" />
+
+        <category android:name="android.intent.category.DEFAULT" />
+        <category android:name="android.intent.category.BROWSABLE" />
+
+        <data
+            android:host="launcher"
+            android:scheme="six-applet" />
+      </intent-filter>
+
+      <intent-filter>
+        <action android:name="android.intent.action.VIEW" />
+
+        <category android:name="android.intent.category.DEFAULT" />
+        <category android:name="android.intent.category.BROWSABLE" />
+
+        <data
+            android:host="browser"
+            android:path="/kiosk"
+            android:scheme="six-applet" />
+      </intent-filter>
+
+      <intent-filter>
+        <action android:name="android.intent.action.VIEW" />
+
+        <category android:name="android.intent.category.DEFAULT" />
+        <category android:name="android.intent.category.BROWSABLE" />
+
+        <data
+            android:host="browser"
+            android:path="/web"
+            android:scheme="six-applet" />
+      </intent-filter>
+
     </activity>
 
     <meta-data

+ 34 - 0
app/src/main/java/com/hzliuzhi/applet/container/MainActivity.kt

@@ -1,12 +1,14 @@
 package com.hzliuzhi.applet.container
 
 import android.annotation.SuppressLint
+import android.content.Intent
 import android.view.KeyEvent
 import android.widget.Toast
 import androidx.activity.compose.LocalActivity
 import androidx.activity.compose.setContent
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
@@ -14,6 +16,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.navigation.NavHostController
 import androidx.navigation.compose.rememberNavController
 import com.hzliuzhi.applet.container.navigation.Host
+import com.hzliuzhi.applet.container.navigation.toRoute
+import com.hzliuzhi.applet.core.router.DeepLink
 import com.hzliuzhi.applet.core.shared.Message
 import com.hzliuzhi.applet.core.shared.Payload
 import com.hzliuzhi.applet.core.shared.SharedFlowHub
@@ -23,6 +27,12 @@ import com.hzliuzhi.applet.scanner.Scanner
 
 class MainActivity : AndroidActivity() {
   private var navController: NavHostController? = null
+  private var pendingDeepLink: String? = null
+
+  override fun onNewIntent(intent: Intent) {
+    super.onNewIntent(intent)
+    handleDeepLink(intent.data?.toString())
+  }
 
   @SuppressLint("RestrictedApi")
   override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -44,6 +54,13 @@ class MainActivity : AndroidActivity() {
         }
       }
 
+      LaunchedEffect(navController) {
+        pendingDeepLink?.takeIf { navController?.graph != null }?.also {
+          handleDeepLink(it)
+          pendingDeepLink = null
+        }
+      }
+
       val context = LocalContext.current
       val owner = LocalLifecycleOwner.current
       Scanner.getInstance(context).observe(owner) { result ->
@@ -52,6 +69,7 @@ class MainActivity : AndroidActivity() {
         } else {
           Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show()
           when {
+            result.code.startsWith(DeepLink().scheme) -> runCatching { handleDeepLink(result.code).let { null } }.getOrNull()
             result.code.startsWith("six:") -> null
             else -> Payload.data(data = result).toJson()
           }
@@ -73,5 +91,21 @@ class MainActivity : AndroidActivity() {
         }
       }
     }
+    handleDeepLink(intent.data?.toString())
+  }
+
+  private fun handleDeepLink(link: String?) {
+    if (navController?.graph == null) {
+      pendingDeepLink = link
+    } else {
+      link?.toRoute()?.also {
+        navController?.apply {
+          navigate(it) {
+            popUpTo(graph.startDestinationId)
+            launchSingleTop = true
+          }
+        }
+      }
+    }
   }
 }

+ 6 - 4
app/src/main/java/com/hzliuzhi/applet/container/navigation/Host.kt

@@ -8,9 +8,10 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.navigation.NavHostController
 import androidx.navigation.compose.NavHost
 import androidx.navigation.compose.rememberNavController
-
 import com.hzliuzhi.applet.browser.navigation.browser
 import com.hzliuzhi.applet.container.R
+import com.hzliuzhi.applet.core.router.navRoute
+import com.hzliuzhi.applet.core.store.SettingStore
 
 @Composable
 fun Host(
@@ -28,11 +29,12 @@ fun Host(
 }
 
 @Composable
-fun rememberRoute(): Any {
+fun rememberRoute(): String {
   val context = LocalContext.current.applicationContext
   return remember {
     runCatching {
-      context.resources.getString(R.string.app_screen).toRoute()
-    }.getOrNull() ?: AppRoute.Home
+      val screen = SettingStore.getInstance(context).screen ?: context.resources.getString(R.string.app_screen)
+      screen.toRoute()
+    }.getOrNull() ?: navRoute(AppRoute.Launcher())
   }
 }

+ 25 - 2
app/src/main/java/com/hzliuzhi/applet/container/navigation/Route.kt

@@ -1,22 +1,45 @@
 package com.hzliuzhi.applet.container.navigation
 
+import android.widget.Toast
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
 import androidx.navigation.NavController
 import androidx.navigation.NavGraphBuilder
-import androidx.navigation.compose.composable
 import com.hzliuzhi.applet.container.ui.HomeScreen
+import com.hzliuzhi.applet.container.ui.LauncherScreen
+import com.hzliuzhi.applet.core.router.DeepLink
+import com.hzliuzhi.applet.core.router.route
 import kotlinx.serialization.Serializable
 
 sealed class AppRoute {
   @Serializable
   data object Home : AppRoute()
+
+  @Serializable
+  data class Launcher(val text: String? = "") : AppRoute()
 }
 
 fun NavGraphBuilder.app(navController: NavController) {
-  composable<AppRoute.Home> { _ ->
+  route<AppRoute.Home> {
     HomeScreen(
       modifier = Modifier.fillMaxSize()
     )
   }
+  route<AppRoute.Launcher> { (route) ->
+    val context = LocalContext.current
+    LauncherScreen(
+      modifier = Modifier.fillMaxSize(),
+      input = route.text,
+      start = {
+        runCatching {
+          val value = if (it.startsWith(DeepLink().scheme)) it else "browser/kiosk?url=$it"
+          value.toRoute()?.also { navController.navigate(it) } ?: throw IllegalArgumentException("无效的路由")
+          value
+        }.onFailure {
+          Toast.makeText(context, "跳转失败: ${it.message}", Toast.LENGTH_SHORT).show()
+        }.getOrNull()
+      }
+    )
+  }
 }

+ 20 - 8
app/src/main/java/com/hzliuzhi/applet/container/navigation/RouteExtra.kt

@@ -1,18 +1,30 @@
 package com.hzliuzhi.applet.container.navigation
 
+import android.util.Log
 import com.hzliuzhi.applet.browser.navigation.BrowserRoute
+import com.hzliuzhi.applet.core.router.DeepLink
+import com.hzliuzhi.applet.core.router.navRoute
 
-data class DeepLink(
-  val scheme: String = "six-applet://",
-)
 
-internal fun String?.toRoute(): Any? {
+internal fun String?.toRoute(): String? {
   val value = this?.replace(DeepLink().scheme, "") ?: ""
   return when {
-    value.startsWith("home") -> AppRoute.Home
-    value.startsWith("browser/update") -> BrowserRoute.Update
-    value.startsWith("browser/web?url=") -> BrowserRoute.Web().copy(url = value.substringAfter("browser/web?url="))
-    value.startsWith("browser/kiosk?url=") -> BrowserRoute.Kiosk().copy(url = value.substringAfter("browser/kiosk?url="))
+    value.startsWith("home") -> navRoute<AppRoute.Home>()
+    value.startsWith("launcher") -> {
+      val text = value.substringAfter("launcher?text=", "")
+      navRoute(AppRoute.Launcher(text = text))
+    }
+
+    value.startsWith("browser/update") -> navRoute<BrowserRoute.Update>()
+    value.startsWith("browser/web?url=") -> {
+      val url = value.substringAfter("browser/web?url=", "")
+      navRoute(BrowserRoute.Web(url = url))
+    }
+
+    value.startsWith("browser/kiosk?url=") -> {
+      val url = value.substringAfter("browser/kiosk?url=", "")
+      navRoute(BrowserRoute.Kiosk(url = url))
+    }
     else -> null
   }
 }

+ 246 - 0
app/src/main/java/com/hzliuzhi/applet/container/ui/LauncherScreen.kt

@@ -0,0 +1,246 @@
+package com.hzliuzhi.applet.container.ui
+
+import android.content.Intent
+import android.provider.Settings
+import android.util.Patterns
+import android.widget.Toast
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.QrCode
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.hzliuzhi.applet.core.store.SettingStore
+import com.hzliuzhi.applet.scanner.ScanResult
+import com.hzliuzhi.applet.scanner.Scanner
+
+
+@Composable
+fun LauncherScreen(
+  input: String?,
+  modifier: Modifier = Modifier,
+  start: (String) -> String?,
+) {
+  val context = LocalContext.current
+  var text by remember { mutableStateOf(input ?: "") }
+  var storeEnabled by remember { mutableStateOf(true) }
+
+  Column(
+    modifier = modifier
+      .fillMaxSize()
+      .padding(24.dp),
+    verticalArrangement = Arrangement.Center
+  ) {
+    LauncherInputField(
+      value = text,
+      onValueChange = { text = it },
+      onDone = {
+        start(text)?.also {
+          SettingStore.getInstance(context).screen = if (storeEnabled) it else null
+        }
+      },
+    )
+    Spacer(modifier = Modifier.height(24.dp))
+    StorageCard(
+      enabled = storeEnabled,
+      enabledChange = { storeEnabled = it }
+    )
+  }
+}
+
+@Composable
+fun LauncherInputField(
+  value: String,
+  onValueChange: (String) -> Unit,
+  onDone: () -> Unit,
+  modifier: Modifier = Modifier,
+) {
+  val context = LocalContext.current
+
+  val scanner = Scanner.getInstance(context)
+  val owner = LocalLifecycleOwner.current
+
+  DisposableEffect(owner, scanner) {
+    val observer: (ScanResult?) -> Unit = { result -> result?.code?.also { onValueChange(it) } }
+    scanner.observe(owner, observer)
+    onDispose { scanner.removeObserver(observer) }
+  }
+
+  val isUrl = remember(value) { Patterns.WEB_URL.matcher(value).matches() }
+
+  val keyboardController = LocalSoftwareKeyboardController.current
+
+  fun done() {
+    if (isUrl) {
+      keyboardController?.hide()
+      onDone()
+    } else Toast.makeText(context, "请输入合法的网址", Toast.LENGTH_SHORT).show()
+  }
+
+
+  OutlinedTextField(
+    value = value,
+    onValueChange = onValueChange,
+    label = { Text("请输入URL") },
+    singleLine = true,
+    isError = value.isNotBlank() && !isUrl,
+    modifier = modifier.fillMaxWidth(),
+    keyboardOptions = KeyboardOptions(
+      keyboardType = KeyboardType.Uri,
+      imeAction = ImeAction.Done
+    ),
+    keyboardActions = KeyboardActions(onDone = { done() }),
+    trailingIcon = {
+      if (value.isBlank()) {
+        Icon(
+          imageVector = Icons.Filled.QrCode,
+          contentDescription = "扫码",
+          modifier = Modifier.clickable {
+            scanner.start()
+            keyboardController?.hide()
+          }
+        )
+      } else {
+        Icon(
+          imageVector = Icons.AutoMirrored.Filled.Send,
+          contentDescription = "完成",
+          modifier = Modifier.clickable { done() }
+        )
+      }
+    }
+  )
+}
+
+@Composable
+fun StorageCard(
+  enabled: Boolean,
+  enabledChange: (Boolean) -> Unit,
+) {
+  val context = LocalContext.current
+
+  val appName = runCatching {
+    val info = context.applicationInfo
+    info.labelRes.takeIf { it != 0 }?.let { context.getString(it) } ?: info.nonLocalizedLabel?.toString()
+  }.getOrNull() ?: "本App"
+
+  Card(
+    modifier = Modifier.fillMaxWidth(),
+    shape = MaterialTheme.shapes.medium,
+  ) {
+    Column(
+      modifier = Modifier.padding(start = 12.dp, end = 12.dp)
+    ) {
+      // 头部:标题+开关
+      Row(
+        modifier = Modifier.fillMaxWidth(),
+        verticalAlignment = Alignment.CenterVertically
+      ) {
+        Text(
+          text = "存储数据",
+          style = MaterialTheme.typography.titleMedium,
+          modifier = Modifier.weight(1f)
+        )
+        Switch(
+          checked = enabled,
+          onCheckedChange = enabledChange
+        )
+      }
+      if (enabled) {
+        Spacer(modifier = Modifier.height(8.dp))
+        Row(verticalAlignment = Alignment.CenterVertically) {
+          Icon(
+            imageVector = Icons.Default.Info,
+            contentDescription = null,
+            tint = MaterialTheme.colorScheme.secondary
+          )
+          Spacer(modifier = Modifier.width(8.dp))
+          Text(
+            text = "如需清除缓存,请在",
+            style = MaterialTheme.typography.bodyMedium,
+            color = MaterialTheme.colorScheme.secondary
+          )
+          Text(
+            text = "设置中操作",
+            style = MaterialTheme.typography.bodyMedium.copy(color = Color(0xFF1976D2), textDecoration = TextDecoration.Underline),
+            modifier = Modifier
+              .clickable {
+                val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+                  data = ("package:" + context.packageName).toUri()
+                }
+                context.startActivity(intent)
+              }
+          )
+        }
+        Spacer(modifier = Modifier.height(12.dp))
+        Text(
+          text = "清除数据:",
+          style = MaterialTheme.typography.bodyMedium,
+          color = MaterialTheme.colorScheme.primary
+        )
+        Spacer(modifier = Modifier.height(8.dp))
+        Column {
+          val steps = listOf(
+            "打开系统设置",
+            "找到应用管理",
+            "选择$appName",
+            "进入存储与缓存",
+            "点击清除全部数据(存储空间)"
+          )
+          steps.forEachIndexed { idx, step ->
+            Row(verticalAlignment = Alignment.CenterVertically) {
+              Surface(
+                shape = MaterialTheme.shapes.small,
+                color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
+                modifier = Modifier.size(24.dp)
+              ) {
+                Box(contentAlignment = Alignment.Center) {
+                  Text("${idx + 1}", style = MaterialTheme.typography.labelMedium)
+                }
+              }
+              Spacer(modifier = Modifier.width(8.dp))
+              Text(step, style = MaterialTheme.typography.bodySmall)
+            }
+            if (idx < steps.lastIndex) Spacer(modifier = Modifier.height(6.dp))
+          }
+        }
+        Spacer(modifier = Modifier.height(8.dp))
+      }
+    }
+  }
+}

+ 0 - 1
app/src/pda/res/values/application.xml

@@ -2,5 +2,4 @@
 <resources>
   <string name="app_id">PDA</string>
   <string name="app_name">中药处方煎配朔源管理(开发版)</string>
-  <string name="app_screen">browser/kiosk?url=http://192.168.1.14:4173/pharmacy/pda/</string>
 </resources>

+ 2 - 0
core/build.gradle.kts

@@ -37,6 +37,7 @@ dependencies {
 
   implementation(libs.androidx.core.ktx)
   implementation(libs.androidx.appcompat)
+  implementation(libs.androidx.navigation.compose)
   implementation(platform(libs.androidx.compose.bom))
   implementation(libs.androidx.ui)
   implementation(libs.androidx.ui.graphics)
@@ -46,4 +47,5 @@ dependencies {
   androidTestImplementation(libs.androidx.espresso.core)
 
   implementation(libs.gson)
+  implementation(libs.kotlin.reflect)
 }

+ 5 - 0
core/src/main/java/com/hzliuzhi/applet/core/router/DeepLink.kt

@@ -0,0 +1,5 @@
+package com.hzliuzhi.applet.core.router
+
+data class DeepLink(
+  val scheme: String = "six-applet://",
+)

+ 194 - 0
core/src/main/java/com/hzliuzhi/applet/core/router/route.kt

@@ -0,0 +1,194 @@
+package com.hzliuzhi.applet.core.router
+
+import android.os.Parcelable
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.runtime.Composable
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import androidx.navigation.navDeepLink
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.reflect.KProperty1
+import kotlin.reflect.KType
+import kotlin.reflect.full.createInstance
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.full.primaryConstructor
+
+
+// region 反射缓存
+val constructorCache = ConcurrentHashMap<Class<*>, kotlin.reflect.KFunction<*>>()
+val memberPropertiesCache = ConcurrentHashMap<Class<*>, Collection<KProperty1<out Any, *>>>()
+
+inline fun <reified T : Any> getPrimaryConstructor(): kotlin.reflect.KFunction<*>? {
+  val key = T::class.java
+  constructorCache[key]?.let { return it }
+  val ctor = T::class.primaryConstructor
+  if (ctor != null) {
+    constructorCache[key] = ctor
+  }
+  return ctor
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun <reified T : Any> getMemberProperties(): Collection<KProperty1<T, *>> {
+  val key = T::class.java
+  memberPropertiesCache[key]?.let { return it as Collection<KProperty1<T, *>> }
+  val props = T::class.memberProperties as Collection<KProperty1<T, *>>
+  memberPropertiesCache[key] = props
+  return props
+}
+// endregion
+
+/**
+ * 根据Kotlin类型自动推断NavType,支持基础类型。
+ * @param type Kotlin反射类型
+ * @return 对应的NavType
+ */
+fun navTypeFor(type: KType): NavType<*> = when (val classifier = type.classifier) {
+  String::class -> NavType.StringType
+  Int::class -> NavType.IntType
+  Long::class -> NavType.LongType
+  Float::class -> NavType.FloatType
+  Boolean::class -> NavType.BoolType
+  else -> NavType.StringType
+}
+
+/**
+ * 自动生成navArgument,支持部分字段有默认参数。
+ * 只为有默认值的参数设置defaultValue,必填参数不设置defaultValue。
+ * @return 包含所有参数的NamedNavArgument列表
+ */
+inline fun <reified T : Any> navArgumentsWithDefault(): List<NamedNavArgument> = T::class.run {
+  val params = getPrimaryConstructor<T>()?.parameters.orEmpty()
+  // 只尝试获取有默认值的参数的默认实例
+  val defaultInstance = try {
+    if (params.all { it.isOptional || it.isVararg }) createInstance() else null
+  } catch (e: Exception) {
+    null
+  }
+  val memberProps = getMemberProperties<T>()
+  params.map { param ->
+    val paramName = param.name!!
+    val type = navTypeFor(param.type)
+    // 只为有默认值的参数设置 defaultValue
+    val hasDefault = param.isOptional || (defaultInstance != null && memberProps.find { it.name == paramName }?.getter?.call(defaultInstance) != null)
+    val defaultValue = if (hasDefault && defaultInstance != null) {
+      memberProps.find { it.name == paramName }?.getter?.call(defaultInstance)
+    } else null
+    navArgument(paramName) {
+      this.type = type
+      defaultValue?.let { value ->
+        @Suppress("IMPLICIT_CAST_TO_ANY")
+        when (type) {
+          NavType.StringType -> value as? String
+          NavType.IntType -> value as? Int
+          NavType.LongType -> value as? Long
+          NavType.FloatType -> value as? Float
+          NavType.BoolType -> value as? Boolean
+          is NavType.EnumType<*> -> value.toString()
+          is NavType.ParcelableType<*> -> value as? Parcelable
+          else -> null
+        }?.let { this.defaultValue = it }
+      }
+    }
+  }
+}
+
+/**
+ * 自动生成路由字符串,如 user?id={id}&name={name}
+ * @return 路由字符串
+ */
+inline fun <reified T : Any> navRoute() = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  val params = getPrimaryConstructor<T>()?.parameters.orEmpty()
+  params.takeIf { it.isNotEmpty() }
+    ?.joinToString("&", prefix = "$name?") { "${it.name}={${it.name}}" }
+    ?: name
+}
+
+inline fun <reified T : Any> navRoute(instance: T): String = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  val memberProps = getMemberProperties<T>()
+  val params = memberProps.mapNotNull { prop ->
+    prop.get(instance)?.let { "${prop.name}=$it" }
+  }
+  params.takeIf { it.isNotEmpty() }
+    ?.joinToString("&", prefix = "$name?")
+    ?: name
+}
+
+/**
+ * NavBackStackEntry自动转为目标data class,支持类型和错误提示
+ * @throws IllegalArgumentException 参数解析或构造失败时抛出
+ * @return 目标类型实例
+ */
+inline fun <reified T : Any> NavBackStackEntry.toRoute(): T = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  // 处理 object 单例类型
+  T::class.objectInstance?.let { return it as T }
+  val params = getPrimaryConstructor<T>()?.parameters.orEmpty()
+  val args = params.associateWith { param ->
+    val paramName = param.name!!
+    try {
+      when (param.type.classifier) {
+        String::class -> arguments?.getString(paramName)
+        Int::class -> arguments?.getInt(paramName)
+        Long::class -> arguments?.getLong(paramName)
+        Float::class -> arguments?.getFloat(paramName)
+        Boolean::class -> arguments?.getBoolean(paramName)
+        else -> arguments?.getString(paramName)
+      }
+    } catch (e: Exception) {
+      throw IllegalArgumentException("参数[$paramName] 解析失败: ${e.message}")
+    }
+  }
+  try {
+    getPrimaryConstructor<T>()!!.callBy(args) as T
+  } catch (e: Exception) {
+    throw IllegalArgumentException("$name 构造失败: ${e.message}")
+  }
+}
+
+/**
+ * NavGraphBuilder.route扩展,自动注册路由
+ * @param content 页面内容Composable
+ * @param deepLinks 可选的DeepLink列表
+ */
+inline fun <reified T : Any> NavGraphBuilder.route(
+  deepLinks: List<NavDeepLink> = emptyList(),
+  noinline content: @Composable AnimatedContentScope.(Pair<T, NavBackStackEntry>) -> Unit,
+) = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  composable(
+    route = navRoute<T>(),
+    arguments = navArgumentsWithDefault<T>(),
+    deepLinks = deepLinks + listOf(navDeepLink { uriPattern = "${DeepLink().scheme}${navRoute<T>()}" })
+  ) {
+    runCatching { content(Pair(it.toRoute(), it)) }.onFailure {
+      val message = "$name 路由参数解析或页面构建失败: ${it.message}"
+      android.util.Log.e("log:route", message, it)
+      throw IllegalStateException(message)
+    }.getOrNull()
+  }
+}
+
+/**
+ * NavController.router扩展,自动跳转并带参数
+ * @param route 路由参数对象
+ * @throws IllegalStateException 跳转失败时抛出
+ */
+inline fun <reified T : Any> NavHostController.router(route: T) = T::class.run {
+  runCatching {
+    navigate(navRoute(route))
+  }.onFailure { it ->
+    val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+    val message = "$name 跳转失败: ${it.message}"
+    android.util.Log.e("log:router", message, it)
+    throw IllegalStateException(message)
+  }.getOrNull()
+}

+ 32 - 0
core/src/main/java/com/hzliuzhi/applet/core/store/SettingStore.kt

@@ -0,0 +1,32 @@
+package com.hzliuzhi.applet.core.store
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+class SettingStore private constructor(private val sp: SharedPreferences) {
+
+
+  var screen: String?
+    get() = sp.getString(KEY_SCREEN, null)?.takeUnless { it.isBlank() }
+    set(value) {
+      sp.edit { value?.takeUnless { it.isBlank() }?.also { putString(KEY_SCREEN, it) } ?: remove(KEY_SCREEN) }
+    }
+
+  companion object {
+    private const val KEY_SCREEN = "screen"
+
+    @Volatile
+    private var instance: SettingStore? = null
+    fun getInstance(context: Context): SettingStore {
+      return instance ?: synchronized(this) {
+        instance ?: run {
+          val sp = context.applicationContext.getSharedPreferences("app_setting", Context.MODE_PRIVATE)
+          SettingStore(sp).also { instance = it }
+        }
+      }
+    }
+  }
+
+
+}

+ 3 - 0
gradle/libs.versions.toml

@@ -1,6 +1,7 @@
 [versions]
 agp = "8.9.3"
 kotlin = "2.1.21"
+kotlinReflect = "2.1.21"
 serializationJson = "1.7.3"
 coreKtx = "1.16.0"
 junit = "4.13.2"
@@ -18,7 +19,9 @@ okhttp = "4.12.0"
 webkit = "1.14.0"
 
 [libraries]
+kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
 androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
 junit = { group = "junit", name = "junit", version.ref = "junit" }
 androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }

+ 5 - 8
library/browser/src/main/java/com/hzliuzhi/applet/browser/navigation/Route.kt

@@ -4,35 +4,32 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.ui.Modifier
 import androidx.navigation.NavController
 import androidx.navigation.NavGraphBuilder
-import androidx.navigation.compose.composable
-import androidx.navigation.toRoute
 import com.hzliuzhi.applet.browser.ui.KioskScreen
 import com.hzliuzhi.applet.browser.ui.UpdateScreen
 import com.hzliuzhi.applet.browser.ui.WebScreen
 import com.hzliuzhi.applet.browser.webview.WebContent
+import com.hzliuzhi.applet.core.router.route
 import kotlinx.serialization.Serializable
 
 sealed class BrowserRoute {
-
   @Serializable
   data class Web(val url: String? = "") : BrowserRoute()
 
   @Serializable
   data class Kiosk(val url: String? = "") : BrowserRoute()
+
   @Serializable
   data object Update : BrowserRoute()
 }
 
 fun NavGraphBuilder.browser(navController: NavController) {
-  composable<BrowserRoute.Web> { backStackEntry ->
-    val route = backStackEntry.toRoute<BrowserRoute.Web>()
+  route<BrowserRoute.Web> { (route) ->
     WebScreen(url = route.url ?: "localhost")
   }
-  composable<BrowserRoute.Kiosk> { backStackEntry ->
-    val route = backStackEntry.toRoute<BrowserRoute.Kiosk>()
+  route<BrowserRoute.Kiosk> { (route) ->
     KioskScreen(content = WebContent.Url(url = route.url ?: "localhost"))
   }
-  composable<BrowserRoute.Update> {
+  route<BrowserRoute.Update> {
     UpdateScreen(modifier = Modifier.fillMaxSize())
   }
 }

+ 3 - 1
library/device/scanner/src/main/java/com/hzliuzhi/applet/scanner/Scanner.kt

@@ -53,7 +53,7 @@ class Scanner private constructor(context: Context, private val impl: ScannerImp
 
               event.payload
                 ?.let { ScanParams.formJson(it) }
-                ?.let { impl.start(applicationContext, it) }
+                ?.let { start(it) }
                 ?.also { seconds ->
                   scanTimeout?.cancel()
                   scanTimeout = scope.launch {
@@ -81,6 +81,8 @@ class Scanner private constructor(context: Context, private val impl: ScannerImp
       .launchIn(scope)
   }
 
+  fun start(params: ScanParams = ScanParams()) = impl.start(applicationContext, params)
+
   companion object {
     @Volatile
     private var instance: Scanner? = null