diff --git a/.gitignore b/.gitignore index 3fd5f58..6c6bd27 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Android-APP/gradlew.bat # Generated server bundle (esbuild output) — CI/local builds regenerate it via # "bun run build:android-bundle" + copy; see Android-APP/AGENTS.md BUILD section. Android-APP/app/src/main/assets/server_bundle/server.cjs +/Android-APP/design/previews diff --git a/Android-APP/app/src/main/AndroidManifest.xml b/Android-APP/app/src/main/AndroidManifest.xml index 684261e..28c253e 100644 --- a/Android-APP/app/src/main/AndroidManifest.xml +++ b/Android-APP/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + themeMode = mode + ThemePrefs.save(this, mode) + }, + ) } } } - @Composable - private fun AppScreen() { - val scope = rememberCoroutineScope() - - var reachable by remember { mutableStateOf(false) } - var loggedIn by remember { mutableStateOf(false) } - var provider by remember { mutableStateOf("bigmodel") } - var plan by remember { mutableStateOf("coding-plan") } - var proxyRunning by remember { mutableStateOf(false) } - var proxyPort by remember { mutableStateOf(0) } - var logCursor by remember { mutableStateOf(0) } - val logs = remember { mutableStateOf>(emptyList()) } - var toast by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - while (true) { - val cc = controlClient - if (cc == null) { - reachable = false + companion object { + var controlClient: ControlClient? = null + } +} + +private const val POLL_INTERVAL_MS = 1500L +private const val MAX_LOG_LINES = 500 +private const val SPARKLINE_MINUTES = 60 + +@Composable +private fun AppScreen(themeMode: ThemeMode, onThemeModeChange: (ThemeMode) -> Unit) { + val scope = rememberCoroutineScope() + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + + var reachable by remember { mutableStateOf(false) } + var loggedIn by remember { mutableStateOf(false) } + var provider by remember { mutableStateOf("bigmodel") } + var plan by remember { mutableStateOf("coding-plan") } + var proxyPort by remember { mutableStateOf(0) } + var proxyRunning by remember { mutableStateOf(false) } + var logCursor by remember { mutableStateOf(0) } + val logs = remember { mutableStateListOf() } + val minuteBuckets = remember { mutableStateListOf>() } + var toast by remember { mutableStateOf(null) } + var tab by rememberSaveable { mutableStateOf(0) } + + // 运行时长:false→true 记起点;每秒刷新一次仅用于英雄卡 uptime + var runningSince by remember { mutableStateOf(null) } + var nowMs by remember { mutableLongStateOf(0L) } + LaunchedEffect(proxyRunning) { + runningSince = if (proxyRunning) System.currentTimeMillis() else null + nowMs = System.currentTimeMillis() + if (proxyRunning) { + while (isActive) { + delay(1000) + nowMs = System.currentTimeMillis() + } + } + } + + // 轮询:status + 增量 getLogs(协议与旧版一致,1.5s) + LaunchedEffect(Unit) { + while (true) { + val cc = MainActivity.controlClient + if (cc == null) { + reachable = false + } else { + val resp = cc.status() + if (resp != null) { + reachable = true + loggedIn = resp.optBoolean("loggedIn", false) + provider = resp.optString("provider", provider) + plan = resp.optString("plan", plan) + proxyPort = resp.optInt("proxyPort", 0) + proxyRunning = proxyPort > 0 } else { - val resp = cc.status() - if (resp != null) { - reachable = true - loggedIn = resp.optBoolean("loggedIn", false) - provider = resp.optString("provider", provider) - plan = resp.optString("plan", plan) - proxyPort = resp.optInt("proxyPort", 0) - proxyRunning = proxyPort > 0 - } else { - reachable = false - } - val logsResp = cc.getLogs(logCursor) - if (logsResp != null && logsResp.optBoolean("ok", false)) { - val next = logsResp.optInt("nextSince", logCursor) - val arr = logsResp.optJSONArray("lines") - if (arr != null && arr.length() > 0) { - val newLines = ArrayList(arr.length()) - for (i in 0 until arr.length()) newLines.add(arr.getString(i)) - logs.value = (logs.value + newLines).takeLast(MAX_LOG_LINES) + reachable = false + } + val logsResp = cc.getLogs(logCursor) + if (logsResp != null && logsResp.optBoolean("ok", false)) { + val next = logsResp.optInt("nextSince", logCursor) + val arr = logsResp.optJSONArray("lines") + if (arr != null && arr.length() > 0) { + val newLines = ArrayList(arr.length()) + for (i in 0 until arr.length()) newLines.add(arr.getString(i)) + logs.addAll(newLines) + while (logs.size > MAX_LOG_LINES) logs.removeAt(0) + // 按分钟桶聚合请求数,供 sparkline 使用 + val bucket = System.currentTimeMillis() / 60000L + if (minuteBuckets.isNotEmpty() && minuteBuckets.last().first == bucket) { + minuteBuckets[minuteBuckets.lastIndex] = + bucket to (minuteBuckets.last().second + newLines.size) + } else { + minuteBuckets.add(bucket to newLines.size) + while (minuteBuckets.size > SPARKLINE_MINUTES) minuteBuckets.removeAt(0) } - logCursor = next } + logCursor = next } - delay(POLL_INTERVAL_MS) + } + delay(POLL_INTERVAL_MS) + } + } + + LaunchedEffect(toast) { + if (toast != null) { + delay(2500) + toast = null + } + } + + val errRegex = remember { Regex("\\b(4\\d\\d|5\\d\\d)\\b") } + val errorCount = logs.count { errRegex.containsMatchIn(it) } + val sparkData: List = remember(minuteBuckets.toList(), nowMs) { + if (minuteBuckets.isEmpty()) { + emptyList() + } else { + val firstMinute = minuteBuckets.first().first + val currentMinute = nowMs / 60000L + (firstMinute..currentMinute) + .map { m -> minuteBuckets.firstOrNull { it.first == m }?.second ?: 0 } + .takeLast(SPARKLINE_MINUTES) + } + } + + fun changeProvider(p: String) { + scope.launch { + val r = MainActivity.controlClient?.setConfig(provider = p) + if (r != null && r.optBoolean("ok", false)) { + provider = p + toast = "服务商 → ${if (p == "zai") "Z.AI" else "智谱"}" + } else { + toast = "切换失败: ${r?.optString("error") ?: "Node 未响应"}" } } + } - LaunchedEffect(toast) { - if (toast != null) { - delay(2500) - toast = null + fun changePlan(p: String) { + scope.launch { + val r = MainActivity.controlClient?.setConfig(plan = p) + if (r != null && r.optBoolean("ok", false)) { + plan = p + toast = "套餐 → $p" + } else { + toast = "切换失败: ${r?.optString("error") ?: "Node 未响应"}" } } + } - Column( - modifier = Modifier - .fillMaxSize() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("ZCode Proxy", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) - - SettingsLoginCard( - reachable = reachable, - loggedIn = loggedIn, - provider = provider, - plan = plan, - proxyRunning = proxyRunning, - onProviderChange = { newProvider -> - scope.launch { - val r = controlClient?.setConfig(provider = newProvider) - toast = if (r != null && r.optBoolean("ok", false)) "Provider → $newProvider" - else "setConfig failed: ${r?.optString("error") ?: "unreachable"}" - if (r != null && r.optBoolean("ok", false)) provider = newProvider + fun startLogin() { + scope.launch { + val r = MainActivity.controlClient?.startOAuth(provider) + if (r != null && r.optBoolean("ok", false)) { + val url = r.optString("authorizeUrl") + val customTabsIntent = androidx.browser.customtabs.CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + try { + customTabsIntent.launchUrl(context, android.net.Uri.parse(url)) + } catch (e: Exception) { + val fallback = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) + fallback.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(fallback) + } catch (_: Exception) { } - }, - onPlanChange = { newPlan -> - scope.launch { - val r = controlClient?.setConfig(plan = newPlan) - toast = if (r != null && r.optBoolean("ok", false)) "Plan → $newPlan" - else "setConfig failed: ${r?.optString("error") ?: "unreachable"}" - if (r != null && r.optBoolean("ok", false)) plan = newPlan - } - }, - onLogin = { - scope.launch { - val r = controlClient?.startOAuth(provider) - if (r != null && r.optBoolean("ok", false)) { - val url = r.optString("authorizeUrl") - val customTabsIntent = androidx.browser.customtabs.CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - try { - customTabsIntent.launchUrl(this@MainActivity, android.net.Uri.parse(url)) - } catch (e: Exception) { - val fallback = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url)) - fallback.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - try { startActivity(fallback) } catch (_: Exception) {} - } - } else { - toast = "startOAuth failed: ${r?.optString("error") ?: "unreachable"}" - } - } - }, - onLogout = { - scope.launch { - val r = controlClient?.logout() - toast = if (r != null && r.optBoolean("ok", false)) "Logged out" else "Logout failed" - } - }, - ) + } + } else { + toast = "登录失败: ${r?.optString("error") ?: "Node 未响应"}" + } + } + } - ProxyControlCard( - reachable = reachable, - proxyRunning = proxyRunning, - proxyPort = proxyPort, - loggedIn = loggedIn, - onStart = { - scope.launch { - val r = controlClient?.startProxy() - toast = if (r != null && r.optBoolean("ok", false)) { - "Proxy started on :${r.optInt("port")}" - } else { - "Start failed: ${r?.optString("error") ?: "unreachable"}" + fun logout() { + scope.launch { + val r = MainActivity.controlClient?.logout() + toast = if (r != null && r.optBoolean("ok", false)) "已登出" else "登出失败" + } + } + + fun startProxy() { + scope.launch { + val r = MainActivity.controlClient?.startProxy() + toast = if (r != null && r.optBoolean("ok", false)) { + "代理已启动 · 127.0.0.1:${r.optInt("port")}" + } else { + "启动失败: ${r?.optString("error") ?: "Node 未响应"}" + } + } + } + + fun stopProxy() { + scope.launch { + val r = MainActivity.controlClient?.stopProxy() + toast = if (r != null && r.optBoolean("ok", false)) "代理已停止" else "停止失败: ${r?.optString("error") ?: "Node 未响应"}" + } + } + + val cs = MaterialTheme.colorScheme + + Box(Modifier.fillMaxSize().background(cs.background)) { + Column(Modifier.fillMaxSize().statusBarsPadding()) { + when (tab) { + 0 -> { + // ── 主页 ── + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 4.dp, start = 16.dp, end = 16.dp, bottom = 120.dp), + ) { + item { + TopBar( + subtitle = when { + !reachable -> "本地反向代理 · Node 未响应" + else -> "本地反向代理 · 已连接" + }, + onSettings = { tab = 2 }, + ) + } + item { + HeroCard( + reachable = reachable, + loggedIn = loggedIn, + proxyRunning = proxyRunning, + proxyPort = proxyPort, + plan = plan, + uptimeText = runningSince?.let { formatDuration(nowMs - it) }, + sparkData = sparkData, + clipboard = clipboard, + onCopied = { toast = "已复制 127.0.0.1:$proxyPort" }, + onStart = ::startProxy, + onStop = ::stopProxy, + ) + } + item { + AccountCard( + reachable = reachable, + loggedIn = loggedIn, + provider = provider, + onLogin = ::startLogin, + onLogout = ::logout, + ) + } + item { + AccessConfigCard( + reachable = reachable, + proxyRunning = proxyRunning, + provider = provider, + plan = plan, + onProviderChange = ::changeProvider, + onPlanChange = ::changePlan, + ) + } + item { + LogsPreviewCard( + logs = logs, + errorCount = errorCount, + errRegex = errRegex, + onOpenLogs = { tab = 1 }, + ) } } - }, - onStop = { - scope.launch { - val r = controlClient?.stopProxy() - toast = if (r != null && r.optBoolean("ok", false)) "Proxy stopped" - else "Stop failed: ${r?.optString("error") ?: "unreachable"}" - } - }, - ) + } + 1 -> LogsScreen( + logs = logs, + errRegex = errRegex, + onClear = { logs.clear() }, + ) + 2 -> SettingsScreen( + themeMode = themeMode, + onThemeModeChange = onThemeModeChange, + provider = provider, + plan = plan, + proxyPort = proxyPort, + proxyRunning = proxyRunning, + reachable = reachable, + loggedIn = loggedIn, + ) + } + } - LogsCard(logs.value) + // 底部导航 + Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth()) { + HorizontalDivider(color = cs.outlineVariant, thickness = 1.dp) + Row( + Modifier + .fillMaxWidth() + .background(cs.surfaceContainer) + .navigationBarsPadding() + .height(68.dp), + ) { + NavItem("主页", Icons.Filled.Home, tab == 0, Modifier.weight(1f)) { tab = 0 } + NavItem("日志", Icons.Filled.Menu, tab == 1, Modifier.weight(1f)) { tab = 1 } + NavItem("设置", Icons.Filled.Settings, tab == 2, Modifier.weight(1f)) { tab = 2 } + } + } - toast?.let { msg -> - Box( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF1F2937), RoundedCornerShape(8.dp)) - .padding(10.dp), - ) { - Text(msg, color = Color.White, fontSize = 13.sp) - } + // toast + toast?.let { msg -> + Surface( + color = cs.inverseSurface, + contentColor = cs.inverseOnSurface, + shape = RoundedCornerShape(10.dp), + shadowElevation = 4.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 100.dp), + ) { + Text(msg, fontSize = 13.sp, modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) } } } +} - @Composable - private fun SettingsLoginCard( - reachable: Boolean, - loggedIn: Boolean, - provider: String, - plan: String, - proxyRunning: Boolean, - onProviderChange: (String) -> Unit, - onPlanChange: (String) -> Unit, - onLogin: () -> Unit, - onLogout: () -> Unit, +@Composable +private fun TopBar(subtitle: String, onSettings: () -> Unit) { + val cs = MaterialTheme.colorScheme + val context = LocalContext.current + Row( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Settings & Login", fontWeight = FontWeight.SemiBold, fontSize = 15.sp) - Spacer(Modifier.weight(1f)) - StatusPill(reachable = reachable, loggedIn = loggedIn) - } - - SegmentedRow( - label = "Provider", - options = listOf("zai", "bigmodel"), - selected = provider, - onSelected = onProviderChange, - enabled = !proxyRunning, - ) + val appIcon = remember { + runCatching { + context.packageManager.getApplicationIcon(context.packageName).toBitmap(96, 96) + }.getOrNull() + } + if (appIcon != null) { + // 暗色模式下黑底图标需要垫一层提亮底 + 描边,避免糊进背景(方案 A 设计稿同款) + val dark = isDarkTheme() + val iconShape = RoundedCornerShape(10.dp) + Image( + bitmap = appIcon.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(iconShape) + .then( + if (dark) Modifier + .background(cs.surfaceContainerHigh) + .border(1.dp, cs.outlineVariant, iconShape) + else Modifier, + ), + ) + } else { + Box( + Modifier + .size(40.dp) + .clip(RoundedCornerShape(10.dp)) + .background(cs.primary), + contentAlignment = Alignment.Center, + ) { + Text("Z", color = cs.onPrimary, fontWeight = FontWeight.Bold, fontSize = 20.sp) + } + } + Spacer(Modifier.width(12.dp)) + Column { + Text("ZCode Proxy", fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = cs.onSurface) + Text(subtitle, fontSize = 12.sp, color = cs.onSurfaceVariant) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onSettings) { + Icon(Icons.Filled.Settings, contentDescription = "设置", tint = cs.onSurfaceVariant) + } + } +} - SegmentedRow( - label = "Plan", - options = listOf("coding-plan", "start-plan"), - selected = plan, - onSelected = onPlanChange, - enabled = !proxyRunning, +@Composable +private fun HeroCard( + reachable: Boolean, + loggedIn: Boolean, + proxyRunning: Boolean, + proxyPort: Int, + plan: String, + uptimeText: String?, + sparkData: List, + clipboard: ClipboardManager, + onCopied: () -> Unit, + onStart: () -> Unit, + onStop: () -> Unit, +) { + val cs = MaterialTheme.colorScheme + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(cs.primaryContainer) + .padding(20.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val (dotColor, stateText) = when { + proxyRunning -> successColor() to "运行中" + !reachable -> MaterialTheme.colorScheme.error to "Node 未响应" + !loggedIn -> cs.onSurfaceVariant to "未登录" + else -> cs.onSurfaceVariant to "已停止" + } + StatusDot(dotColor, pulse = proxyRunning) + Spacer(Modifier.width(10.dp)) + Text(stateText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = cs.onPrimaryContainer) + Spacer(Modifier.weight(1f)) + Surface(shape = RoundedCornerShape(50), color = cs.primary.copy(alpha = 0.14f)) { + Text( + plan, + fontFamily = Mono, + fontSize = 12.sp, + color = cs.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp), ) - - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Button( - onClick = onLogin, - enabled = reachable && !loggedIn, - modifier = Modifier.weight(1f).semantics { testTag = "loginButton" }, - ) { Text(if (loggedIn) "Logged In" else "OAuth Login") } - OutlinedButton( - onClick = onLogout, - enabled = reachable && loggedIn, - modifier = Modifier.weight(1f).semantics { testTag = "logoutButton" }, - ) { Text("Logout") } + } + } + Spacer(Modifier.height(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + if (proxyRunning) "127.0.0.1:$proxyPort" else "未启动", + fontFamily = Mono, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + color = cs.onPrimaryContainer, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (proxyRunning) { + Surface( + shape = RoundedCornerShape(50), + color = Color.Transparent, + border = BorderStroke(1.5.dp, cs.primary.copy(alpha = 0.45f)), + onClick = { + clipboard.setText(AnnotatedString("http://127.0.0.1:$proxyPort")) + onCopied() + }, + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CopyGlyph(cs.onPrimaryContainer.copy(alpha = 0.85f)) + Spacer(Modifier.width(6.dp)) + Text("复制", fontSize = 13.sp, color = cs.onPrimaryContainer) + } } } } + Spacer(Modifier.height(14.dp)) + Row(verticalAlignment = Alignment.Bottom) { + Column(Modifier.weight(1f)) { + Text("近 60 分钟请求", fontSize = 11.sp, color = cs.onPrimaryContainer.copy(alpha = 0.65f)) + Sparkline( + data = sparkData, + color = cs.primary, + modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.width(12.dp)) + Text( + uptimeText?.let { "UP $it" } ?: "UP —", + fontFamily = Mono, + fontSize = 12.sp, + color = cs.onPrimaryContainer.copy(alpha = 0.65f), + ) + } + Spacer(Modifier.height(14.dp)) + Button( + onClick = if (proxyRunning) onStop else onStart, + enabled = if (proxyRunning) reachable else reachable && loggedIn, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors( + containerColor = cs.primary, + contentColor = cs.onPrimary, + disabledContainerColor = cs.onSurfaceVariant.copy(alpha = 0.25f), + disabledContentColor = cs.onSurfaceVariant, + ), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + .semantics { testTag = if (proxyRunning) "stopButton" else "startButton" }, + ) { + if (proxyRunning) { + Box( + Modifier + .size(14.dp) + .clip(RoundedCornerShape(3.dp)) + .background(cs.onPrimary), + ) + Spacer(Modifier.width(10.dp)) + Text("停止代理", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } else { + Text("启动代理", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } + } } +} - @Composable - private fun ProxyControlCard( - reachable: Boolean, - proxyRunning: Boolean, - proxyPort: Int, - loggedIn: Boolean, - onStart: () -> Unit, - onStop: () -> Unit, +@Composable +private fun AccountCard( + reachable: Boolean, + loggedIn: Boolean, + provider: String, + onLogin: () -> Unit, + onLogout: () -> Unit, +) { + val cs = MaterialTheme.colorScheme + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(cs.surfaceContainerLow) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + Box( + Modifier + .size(48.dp) + .clip(CircleShape) + .background(cs.primaryContainer), + contentAlignment = Alignment.Center, ) { - Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Proxy Server", fontWeight = FontWeight.SemiBold, fontSize = 15.sp) + Text("Z", fontSize = 22.sp, fontWeight = FontWeight.Bold, color = cs.onPrimaryContainer) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + if (provider == "zai") "Z.AI 账号" else "智谱账号", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = cs.onSurface, + ) + Spacer(Modifier.height(3.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (loggedIn) successColor() else cs.error), + ) + Spacer(Modifier.width(6.dp)) Text( - if (proxyRunning) "Running on 127.0.0.1:$proxyPort" - else if (!reachable) "Node unreachable" - else if (!loggedIn) "Not logged in — login first" - else "Stopped", + if (loggedIn) "已登录 · OAuth 授权" else "未登录", fontSize = 13.sp, - color = if (proxyRunning) Color(0xFF15803D) else MaterialTheme.colorScheme.onSurfaceVariant, + color = cs.onSurfaceVariant, ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Button( - onClick = onStart, - enabled = reachable && !proxyRunning && loggedIn, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF16A34A)), - modifier = Modifier.weight(1f).semantics { testTag = "startButton" }, - ) { Text("Start Server") } - Button( - onClick = onStop, - enabled = reachable && proxyRunning, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFDC2626)), - modifier = Modifier.weight(1f).semantics { testTag = "stopButton" }, - ) { Text("Stop Server") } - } + } + } + if (loggedIn) { + OutlinedButton( + onClick = onLogout, + enabled = reachable, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = cs.error, + disabledContentColor = cs.error.copy(alpha = 0.38f), + ), + border = BorderStroke(1.5.dp, cs.error.copy(alpha = if (reachable) 0.55f else 0.25f)), + modifier = Modifier.semantics { testTag = "logoutButton" }, + ) { + Text("登出", fontSize = 14.sp, fontWeight = FontWeight.Medium) + } + } else { + Button( + onClick = onLogin, + enabled = reachable, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors(containerColor = cs.primary, contentColor = cs.onPrimary), + modifier = Modifier.semantics { testTag = "loginButton" }, + ) { + Text("登录", fontSize = 14.sp, fontWeight = FontWeight.Medium) } } } +} - @Composable - private fun LogsCard(lines: List) { - val listState = rememberLazyListState() - LaunchedEffect(lines.size) { - if (lines.isNotEmpty()) listState.animateScrollToItem(lines.lastIndex) +@Composable +private fun AccessConfigCard( + reachable: Boolean, + proxyRunning: Boolean, + provider: String, + plan: String, + onProviderChange: (String) -> Unit, + onPlanChange: (String) -> Unit, +) { + val cs = MaterialTheme.colorScheme + val enabled = reachable && !proxyRunning + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(cs.surfaceContainerLow) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("接入配置", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = cs.onSurface) + Spacer(Modifier.weight(1f)) + Text( + when { + proxyRunning -> "运行中 · 切换已锁定" + !reachable -> "Node 未响应" + else -> "停止代理后可切换" + }, + fontSize = 12.sp, + color = dimColor(), + ) } - Card( - modifier = Modifier.fillMaxWidth().heightIn(min = 180.dp, max = 600.dp), - colors = CardDefaults.cardColors(containerColor = Color(0xFF111827)), - ) { - Column(modifier = Modifier.padding(8.dp)) { + HorizontalDivider(color = cs.outlineVariant, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("服务商", fontSize = 13.sp, color = cs.onSurfaceVariant, modifier = Modifier.width(52.dp)) + SegChip("Z.AI", provider == "zai", enabled, modifier = Modifier.weight(1f), fill = true) { onProviderChange("zai") } + Spacer(Modifier.width(8.dp)) + SegChip("智谱", provider == "bigmodel", enabled, modifier = Modifier.weight(1f), fill = true) { onProviderChange("bigmodel") } + } + Spacer(Modifier.height(14.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("套餐", fontSize = 13.sp, color = cs.onSurfaceVariant, modifier = Modifier.width(52.dp)) + SegChip("coding-plan", plan == "coding-plan", enabled, modifier = Modifier.weight(1f), fill = true, mono = true) { onPlanChange("coding-plan") } + Spacer(Modifier.width(8.dp)) + SegChip("start-plan", plan == "start-plan", enabled, modifier = Modifier.weight(1f), fill = true, mono = true) { onPlanChange("start-plan") } + } + } +} + +@Composable +private fun LogsPreviewCard( + logs: List, + errorCount: Int, + errRegex: Regex, + onOpenLogs: () -> Unit, +) { + val cs = MaterialTheme.colorScheme + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(cs.surfaceContainerLow) + .clickable(onClick = onOpenLogs) + .padding(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("实时日志", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = cs.onSurface) + Spacer(Modifier.width(8.dp)) + Surface(shape = RoundedCornerShape(50), color = cs.secondaryContainer) { Text( - "Logs (${lines.size})", - color = Color(0xFF9CA3AF), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 4.dp, bottom = 4.dp), + "${logs.size}", + fontFamily = Mono, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + color = cs.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), ) - HorizontalDivider(color = Color(0xFF374151)) - if (lines.isEmpty()) { - Text( - "No logs yet", - color = Color(0xFF6B7280), - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(8.dp), - ) - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxWidth().padding(4.dp), - ) { - items(lines.size) { idx -> - Text( - lines[idx], - color = Color(0xFFD1D5DB), - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(vertical = 1.dp), - ) - } - } - } } + Spacer(Modifier.weight(1f)) + Text( + "错误 $errorCount", + fontFamily = Mono, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = if (errorCount == 0) successColor() else cs.error, + ) + Spacer(Modifier.width(10.dp)) + Text("查看全部", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = cs.primary) + Icon( + Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = cs.primary, + modifier = Modifier.size(18.dp), + ) } + HorizontalDivider(color = cs.outlineVariant, thickness = 1.dp, modifier = Modifier.padding(vertical = 10.dp)) + if (logs.isEmpty()) { + Text( + "还没有请求 — 在编码工具里发一次对话试试", + fontSize = 12.sp, + color = dimColor(), + modifier = Modifier.padding(vertical = 10.dp), + ) + } else { + logs.takeLast(5).forEach { line -> + Text( + line, + fontFamily = Mono, + fontSize = 11.sp, + lineHeight = 17.sp, + color = if (errRegex.containsMatchIn(line)) cs.error else cs.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(vertical = 2.dp), + ) + } + } + Text( + "点击卡片或右上角「查看全部」查看完整日志", + fontSize = 12.sp, + color = dimColor(), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), + ) } +} - @Composable - private fun SegmentedRow( - label: String, - options: List, - selected: String, - onSelected: (String) -> Unit, - enabled: Boolean, - ) { +@Composable +private fun LogsScreen(logs: MutableList, errRegex: Regex, onClear: () -> Unit) { + val cs = MaterialTheme.colorScheme + val clipboard = LocalClipboardManager.current + var filter by rememberSaveable { mutableStateOf(0) } // 0 全部 1 成功 2 错误 + val filtered = remember(logs.size, filter) { + when (filter) { + 1 -> logs.filter { it.contains("\\b2\\d\\d\\b".toRegex()) } + 2 -> logs.filter { errRegex.containsMatchIn(it) } + else -> logs.toList() + } + } + val listState = rememberLazyListState() + LaunchedEffect(filtered.size) { + if (filtered.isNotEmpty()) listState.scrollToItem(filtered.lastIndex) + } + Column(Modifier.fillMaxSize()) { Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), ) { - Text(label, fontSize = 13.sp, modifier = Modifier.width(70.dp)) - options.forEach { opt -> - val isSelected = opt == selected - Button( - onClick = { onSelected(opt) }, - enabled = enabled && !isSelected, - colors = if (isSelected) { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ) - } else { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - ) - }, - shape = RoundedCornerShape(8.dp), - modifier = Modifier - .padding(end = 8.dp) - .height(36.dp), - ) { - Text(if (isSelected) "✓ $opt" else opt, fontSize = 12.sp) + Text("实时日志 (${filtered.size})", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = cs.onSurface) + Spacer(Modifier.weight(1f)) + TextButton( + onClick = { clipboard.setText(AnnotatedString(filtered.joinToString("\n"))) }, + enabled = filtered.isNotEmpty(), + ) { Text("复制", fontSize = 13.sp) } + TextButton(onClick = onClear, enabled = logs.isNotEmpty()) { Text("清屏", fontSize = 13.sp) } + } + Row(Modifier.padding(horizontal = 16.dp)) { + SegChip("全部", filter == 0, true) { filter = 0 } + Spacer(Modifier.width(8.dp)) + SegChip("成功", filter == 1, true) { filter = 1 } + Spacer(Modifier.width(8.dp)) + SegChip("错误", filter == 2, true) { filter = 2 } + } + HorizontalDivider(color = cs.outlineVariant, thickness = 1.dp, modifier = Modifier.padding(vertical = 8.dp)) + if (filtered.isEmpty()) { + Text( + "暂无日志", + fontSize = 13.sp, + color = dimColor(), + modifier = Modifier.padding(16.dp), + ) + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentPadding = PaddingValues(bottom = 110.dp, top = 4.dp), + ) { + items(filtered.size) { idx -> + val line = filtered[idx] + Text( + line, + fontFamily = Mono, + fontSize = 11.sp, + lineHeight = 17.sp, + color = if (errRegex.containsMatchIn(line)) cs.error else cs.onSurfaceVariant, + modifier = Modifier.padding(vertical = 1.dp), + ) } } } } +} - @Composable - private fun StatusPill(reachable: Boolean, loggedIn: Boolean) { - val (color, text) = when { - !reachable -> Color(0xFFEF4444) to "unreachable" - !loggedIn -> Color(0xFFF59E0B) to "logged out" - else -> Color(0xFF22C55E) to "ready" +@Composable +private fun SettingsScreen( + themeMode: ThemeMode, + onThemeModeChange: (ThemeMode) -> Unit, + provider: String, + plan: String, + proxyPort: Int, + proxyRunning: Boolean, + reachable: Boolean, + loggedIn: Boolean, +) { + val cs = MaterialTheme.colorScheme + val context = LocalContext.current + val versionName = remember { + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull() ?: "—" + } + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 120.dp), + ) { + Text("设置", fontSize = 20.sp, fontWeight = FontWeight.SemiBold, color = cs.onSurface, modifier = Modifier.padding(vertical = 10.dp)) + CardBlock(title = "外观") { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("主题", fontSize = 13.sp, color = cs.onSurfaceVariant, modifier = Modifier.width(64.dp)) + SegChip("跟随系统", themeMode == ThemeMode.FOLLOW_SYSTEM, true) { onThemeModeChange(ThemeMode.FOLLOW_SYSTEM) } + Spacer(Modifier.width(8.dp)) + SegChip("亮色", themeMode == ThemeMode.LIGHT, true) { onThemeModeChange(ThemeMode.LIGHT) } + Spacer(Modifier.width(8.dp)) + SegChip("暗色", themeMode == ThemeMode.DARK, true) { onThemeModeChange(ThemeMode.DARK) } + } + Spacer(Modifier.height(6.dp)) + Text("跟随系统时,深色模式开关即时生效", fontSize = 12.sp, color = dimColor()) } + Spacer(Modifier.height(12.dp)) + CardBlock(title = "接入信息") { + SettingRow("服务商", if (provider == "zai") "Z.AI" else "智谱") + SettingRow("套餐", plan) + SettingRow( + "状态", + when { + proxyRunning -> "127.0.0.1:$proxyPort · 运行中" + reachable -> "未启动" + else -> "Node 未响应" + }, + valueColor = if (proxyRunning) successColor() else cs.onSurface, + ) + SettingRow("登录", if (loggedIn) "已登录" else "未登录") + Spacer(Modifier.height(4.dp)) + Text("切换服务商/套餐在主页「接入配置」卡", fontSize = 12.sp, color = dimColor()) + } + Spacer(Modifier.height(12.dp)) + CardBlock(title = "关于") { + SettingRow("应用", "ZCode Proxy") + SettingRow("版本", versionName) + SettingRow("控制协议", "Node · 127.0.0.1 本地监听") + Spacer(Modifier.height(4.dp)) + Text("上游:Z.AI / 智谱开放平台(OAuth 登录)", fontSize = 12.sp, color = dimColor()) + } + } +} + +@Composable +private fun CardBlock(title: String, content: @Composable () -> Unit) { + val cs = MaterialTheme.colorScheme + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(cs.surfaceContainerLow) + .padding(16.dp), + ) { + Text(title, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = cs.onSurface) + HorizontalDivider(color = cs.outlineVariant, thickness = 1.dp, modifier = Modifier.padding(vertical = 12.dp)) + content() + } +} + +@Composable +private fun SettingRow(label: String, value: String, valueColor: Color = MaterialTheme.colorScheme.onSurface) { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.weight(1f)) + Text(value, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = valueColor) + } +} + +@Composable +private fun NavItem(label: String, icon: androidx.compose.ui.graphics.vector.ImageVector, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val cs = MaterialTheme.colorScheme + Column( + modifier + .fillMaxSize() + .clickable(onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { Box( - modifier = Modifier - .background(color, RoundedCornerShape(10.dp)) - .padding(horizontal = 10.dp, vertical = 3.dp), + Modifier + .clip(RoundedCornerShape(50)) + .background(if (selected) cs.secondaryContainer else Color.Transparent) + .padding(horizontal = 16.dp, vertical = 2.dp), ) { - Text(text, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Medium) + Icon( + icon, + contentDescription = label, + tint = if (selected) cs.primary else cs.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) } + Text( + label, + fontSize = 11.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) cs.primary else cs.onSurfaceVariant, + ) } +} - companion object { - var controlClient: ControlClient? = null - private const val POLL_INTERVAL_MS = 1500L - private const val MAX_LOG_LINES = 500 +@Composable +private fun SegChip( + text: String, + selected: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, + fill: Boolean = false, + mono: Boolean = false, + onClick: () -> Unit, +) { + val cs = MaterialTheme.colorScheme + Surface( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(50), + color = if (selected) cs.primary else cs.background, + border = if (selected) null else BorderStroke(1.dp, cs.outlineVariant), + modifier = modifier + .then(if (fill) Modifier.fillMaxWidth() else Modifier) + .semantics { testTag = "seg_$text" }, + ) { + Text( + text, + fontFamily = if (mono) Mono else null, + fontSize = if (mono) 12.sp else 13.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = when { + selected -> cs.onPrimary + !enabled -> cs.onSurfaceVariant.copy(alpha = 0.5f) + else -> cs.onSurfaceVariant + }, + textAlign = if (fill) TextAlign.Center else null, + maxLines = 1, + modifier = Modifier + .padding(horizontal = if (mono) 8.dp else 16.dp, vertical = 8.dp) + .then(if (fill) Modifier.fillMaxWidth() else Modifier), + ) } } + +@Composable +private fun StatusDot(color: Color, pulse: Boolean) { + if (pulse) { + val alpha by rememberInfiniteTransition(label = "statusPulse").animateFloat( + initialValue = 0.12f, + targetValue = 0.4f, + animationSpec = infiniteRepeatable(tween(1100, easing = LinearEasing), RepeatMode.Reverse), + label = "statusPulseAlpha", + ) + Box(contentAlignment = Alignment.Center) { + Box(Modifier.size(24.dp).clip(CircleShape).background(color.copy(alpha = alpha))) + Box(Modifier.size(12.dp).clip(CircleShape).background(color)) + } + } else { + Box(Modifier.size(12.dp).clip(CircleShape).background(color)) + } +} + +@Composable +private fun Sparkline(data: List, color: Color, modifier: Modifier = Modifier) { + // 签名动效:末点呼吸(与状态灯同一节奏),面积填充取 0.14f 弱化层次 + val pulseAlpha by rememberInfiniteTransition(label = "sparkPulse").animateFloat( + initialValue = 0.12f, + targetValue = 0.4f, + animationSpec = infiniteRepeatable(tween(1100, easing = LinearEasing), RepeatMode.Reverse), + label = "sparkPulseAlpha", + ) + Canvas(modifier.height(36.dp)) { + if (data.size < 2) { + val y = size.height / 2 + drawLine( + color = color.copy(alpha = 0.3f), + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 4f, + cap = StrokeCap.Round, + ) + } else { + val maxV = (data.maxOrNull() ?: 1).coerceAtLeast(1) + val step = size.width / (data.size - 1) + val points = data.mapIndexed { i, v -> + Offset(i * step, size.height * (1f - (v.toFloat() / maxV)).coerceIn(0.08f, 1f)) + } + val line = Path().apply { + moveTo(points.first().x, points.first().y) + points.drop(1).forEach { lineTo(it.x, it.y) } + } + val area = Path().apply { + addPath(line) + lineTo(points.last().x, size.height) + lineTo(points.first().x, size.height) + close() + } + drawPath(area, color.copy(alpha = 0.14f)) + drawPath(line, color, style = Stroke(width = 4f, cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawCircle(color.copy(alpha = pulseAlpha), radius = 11f, center = points.last()) + drawCircle(color, radius = 5.5f, center = points.last()) + } + } +} + +@Composable +private fun CopyGlyph(color: Color) { + Box(Modifier.size(15.dp)) { + Box( + Modifier + .align(Alignment.TopStart) + .size(width = 10.dp, height = 12.dp) + .border(1.5.dp, color, RoundedCornerShape(2.dp)), + ) + Box( + Modifier + .align(Alignment.BottomEnd) + .size(width = 10.dp, height = 12.dp) + .clip(RoundedCornerShape(2.dp)) + .background(color), + ) + } +} + +private fun formatDuration(ms: Long): String { + val totalSeconds = ms / 1000 + val h = totalSeconds / 3600 + val m = (totalSeconds % 3600) / 60 + val s = totalSeconds % 60 + return "%02d:%02d:%02d".format(h, m, s) +} diff --git a/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Color.kt b/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Color.kt new file mode 100644 index 0000000..f0f4e7f --- /dev/null +++ b/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Color.kt @@ -0,0 +1,56 @@ +package com.zcode.proxy.ui.theme + +import androidx.compose.ui.graphics.Color + +/* + * 中国传统色 token(只取色相,按 WCAG 微调明度;无国风符号)。 + * 语义:靛青/月白蓝=primary,松绿=成功,胭脂=错误与登出,秋香/缃=429 等待。 + */ + +// 亮色:霜白/月白 + 靛青 +val LightSurface = Color(0xFFF5F9FA) // 霜白 +val LightSurfaceContainerLow = Color(0xFFE8F0F3) // 月白(卡片) +val LightSurfaceContainer = Color(0xFFE0EAEE) +val LightSurfaceContainerHigh = Color(0xFFD8E3E8) +val LightSurfaceContainerHighest = Color(0xFFCFDCE2) +val LightSurfaceContainerLowest = Color(0xFFFFFFFF) +val LightPrimary = Color(0xFF177CB0) // 靛青 +val LightOnPrimary = Color(0xFFFFFFFF) +val LightPrimaryContainer = Color(0xFFD8EAEF) // 月白(英雄卡) +val LightOnPrimaryContainer = Color(0xFF123B4E) // 绀青 +val LightSecondary = Color(0xFF14587D) +val LightSecondaryContainer = Color(0xFFD8EAEF) +val LightOnSecondaryContainer = Color(0xFF14587D) +val LightTertiary = Color(0xFF21A675) // 松绿(在线/200) +val LightOnTertiary = Color(0xFFFFFFFF) +val LightError = Color(0xFF9D2933) // 胭脂(错误/登出) +val LightOnError = Color(0xFFFFFFFF) +val LightOnSurface = Color(0xFF1F2A30) // 鸦青 +val LightOnSurfaceVariant = Color(0xFF5A6B74) // 黛灰 +val LightOutline = Color(0xFF9FB4BE) +val LightOutlineVariant = Color(0xFFCBD9DE) +val LightDim = Color(0xFF7C8B92) + +// 暗色:漆黑/鸦青 + 月白蓝 +val DarkSurface = Color(0xFF12181D) // 漆黑 +val DarkSurfaceContainerLow = Color(0xFF1C2429) // 鸦青黑(卡片) +val DarkSurfaceContainer = Color(0xFF222B31) +val DarkSurfaceContainerHigh = Color(0xFF2A343A) +val DarkSurfaceContainerHighest = Color(0xFF323E45) +val DarkSurfaceContainerLowest = Color(0xFF0D1216) +val DarkPrimary = Color(0xFF7EC3DF) // 月白蓝 +val DarkOnPrimary = Color(0xFF0C2A38) +val DarkPrimaryContainer = Color(0xFF17475E) // 绀青(英雄卡) +val DarkOnPrimaryContainer = Color(0xFFD9EEF6) // 月白 +val DarkSecondary = Color(0xFF6FB9D8) +val DarkSecondaryContainer = Color(0xFF1D5A77) +val DarkOnSecondaryContainer = Color(0xFFC4E3EF) +val DarkTertiary = Color(0xFF5BC89B) // 松绿亮 +val DarkOnTertiary = Color(0xFF0A2E1F) +val DarkError = Color(0xFFE06B76) // 胭脂亮 +val DarkOnError = Color(0xFF3A0A10) +val DarkOnSurface = Color(0xFFDEE7EA) +val DarkOnSurfaceVariant = Color(0xFF9DAEB6) +val DarkOutline = Color(0xFF5F707A) +val DarkOutlineVariant = Color(0xFF2E3A40) +val DarkDim = Color(0xFF76878F) diff --git a/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Theme.kt b/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Theme.kt new file mode 100644 index 0000000..ee3e042 --- /dev/null +++ b/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Theme.kt @@ -0,0 +1,174 @@ +package com.zcode.proxy.ui.theme + +import android.app.Activity +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat + +/** 外观三档:跟随系统(默认)/ 亮色 / 暗色。偏好存 SharedPreferences。 */ +enum class ThemeMode { + FOLLOW_SYSTEM, + LIGHT, + DARK, +} + +/** 当前生效的暗色状态(含手动覆盖后),组件据此做暗色专属处理(如图标垫边)。 */ +val LocalDarkTheme = staticCompositionLocalOf { false } + +@Composable +fun isDarkTheme(): Boolean = LocalDarkTheme.current + +object ThemePrefs { + private const val PREFS = "ui_prefs" + private const val KEY_THEME_MODE = "theme_mode" + + fun load(context: Context): ThemeMode { + val ordinal = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_THEME_MODE, ThemeMode.FOLLOW_SYSTEM.ordinal) + return ThemeMode.entries.getOrElse(ordinal) { ThemeMode.FOLLOW_SYSTEM } + } + + fun save(context: Context, mode: ThemeMode) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putInt(KEY_THEME_MODE, mode.ordinal) + .apply() + } +} + +private val LightColors = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + primaryContainer = LightPrimaryContainer, + onPrimaryContainer = LightOnPrimaryContainer, + secondary = LightSecondary, + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = LightSecondaryContainer, + onSecondaryContainer = LightOnSecondaryContainer, + tertiary = LightTertiary, + onTertiary = LightOnTertiary, + error = LightError, + onError = LightOnError, + background = LightSurface, + onBackground = LightOnSurface, + surface = LightSurface, + onSurface = LightOnSurface, + surfaceVariant = LightSurfaceContainerLow, + onSurfaceVariant = LightOnSurfaceVariant, + outline = LightOutline, + outlineVariant = LightOutlineVariant, + surfaceContainerLowest = LightSurfaceContainerLowest, + surfaceContainerLow = LightSurfaceContainerLow, + surfaceContainer = LightSurfaceContainer, + surfaceContainerHigh = LightSurfaceContainerHigh, + surfaceContainerHighest = LightSurfaceContainerHighest, +) + +private val DarkColors = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + primaryContainer = DarkPrimaryContainer, + onPrimaryContainer = DarkOnPrimaryContainer, + secondary = DarkSecondary, + onSecondary = Color(0xFF07161F), + secondaryContainer = DarkSecondaryContainer, + onSecondaryContainer = DarkOnSecondaryContainer, + tertiary = DarkTertiary, + onTertiary = DarkOnTertiary, + error = DarkError, + onError = DarkOnError, + background = DarkSurface, + onBackground = DarkOnSurface, + surface = DarkSurface, + onSurface = DarkOnSurface, + surfaceVariant = DarkSurfaceContainerLow, + onSurfaceVariant = DarkOnSurfaceVariant, + outline = DarkOutline, + outlineVariant = DarkOutlineVariant, + surfaceContainerLowest = DarkSurfaceContainerLowest, + surfaceContainerLow = DarkSurfaceContainerLow, + surfaceContainer = DarkSurfaceContainer, + surfaceContainerHigh = DarkSurfaceContainerHigh, + surfaceContainerHighest = DarkSurfaceContainerHighest, +) + +private val AppShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(10.dp), + medium = RoundedCornerShape(20.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(28.dp), +) + +/** 全局视觉缩放:整体缩小 5%(dp+sp 同缩),窄屏不易挤压。 */ +private const val UiScale = 0.95f + +/** + * 跟随系统同步链路:系统深色开关 → Configuration.uiMode 变化 → + * MainActivity 未声明 configChanges,Activity 重建 → isSystemInDarkTheme() + * 取新值重组。手动三档覆盖由调用方持久化后经 [themeMode] 传入。 + */ +@Composable +fun ZcodeTheme( + themeMode: ThemeMode = ThemeMode.FOLLOW_SYSTEM, + content: @Composable () -> Unit, +) { + val dark = when (themeMode) { + ThemeMode.FOLLOW_SYSTEM -> isSystemInDarkTheme() + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + val colorScheme = if (dark) DarkColors else LightColors + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !dark + isAppearanceLightNavigationBars = !dark + } + } + } + val baseDensity = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density(baseDensity.density * UiScale, baseDensity.fontScale * UiScale), + LocalDarkTheme provides dark, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + shapes = AppShapes, + content = content, + ) + } +} + +/** 语义色取值捷径:在线/成功=松绿,429/等待=秋香·缃。 */ +val lightSuccess = Color(0xFF21A675) +val darkSuccess = Color(0xFF5BC89B) +val lightWarning = Color(0xFFA9760B) +val darkWarning = Color(0xFFE0BC5E) + +@Composable +fun successColor(): Color = if (isSystemInDarkTheme()) darkSuccess else lightSuccess + +@Composable +fun warningColor(): Color = if (isSystemInDarkTheme()) darkWarning else lightWarning + +/** 卡片内的弱化文字(日志 dim / 提示)。 */ +@Composable +fun dimColor(): Color = if (isSystemInDarkTheme()) DarkDim else LightDim diff --git a/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Type.kt b/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Type.kt new file mode 100644 index 0000000..9183441 --- /dev/null +++ b/Android-APP/app/src/main/java/com/zcode/proxy/ui/theme/Type.kt @@ -0,0 +1,20 @@ +package com.zcode.proxy.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import com.zcode.proxy.R + +/** + * 数据声部(地址 / 数值 / 日志)专用等宽字体:JetBrains Mono(SIL OFL 1.1, + * 许可证全文见 Android-APP/fonts-OFL.txt)。CJK 字形回落系统字体。 + */ +val Mono = FontFamily( + Font(R.font.jetbrainsmono_regular, FontWeight.Normal), + Font(R.font.jetbrainsmono_medium, FontWeight.Medium), + Font(R.font.jetbrainsmono_semibold, FontWeight.SemiBold), + Font(R.font.jetbrainsmono_bold, FontWeight.Bold), +) + +val AppTypography = Typography() diff --git a/Android-APP/app/src/main/res/font/jetbrainsmono_bold.ttf b/Android-APP/app/src/main/res/font/jetbrainsmono_bold.ttf new file mode 100644 index 0000000..cd1bee0 Binary files /dev/null and b/Android-APP/app/src/main/res/font/jetbrainsmono_bold.ttf differ diff --git a/Android-APP/app/src/main/res/font/jetbrainsmono_medium.ttf b/Android-APP/app/src/main/res/font/jetbrainsmono_medium.ttf new file mode 100644 index 0000000..dc2e5d0 Binary files /dev/null and b/Android-APP/app/src/main/res/font/jetbrainsmono_medium.ttf differ diff --git a/Android-APP/app/src/main/res/font/jetbrainsmono_regular.ttf b/Android-APP/app/src/main/res/font/jetbrainsmono_regular.ttf new file mode 100644 index 0000000..711830e Binary files /dev/null and b/Android-APP/app/src/main/res/font/jetbrainsmono_regular.ttf differ diff --git a/Android-APP/app/src/main/res/font/jetbrainsmono_semibold.ttf b/Android-APP/app/src/main/res/font/jetbrainsmono_semibold.ttf new file mode 100644 index 0000000..2bf8e48 Binary files /dev/null and b/Android-APP/app/src/main/res/font/jetbrainsmono_semibold.ttf differ diff --git a/Android-APP/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android-APP/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..eaada2a Binary files /dev/null and b/Android-APP/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Android-APP/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Android-APP/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..ca3b474 Binary files /dev/null and b/Android-APP/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Android-APP/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android-APP/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e354fc1 Binary files /dev/null and b/Android-APP/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Android-APP/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android-APP/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..f06b410 Binary files /dev/null and b/Android-APP/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Android-APP/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android-APP/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..902681a Binary files /dev/null and b/Android-APP/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Android-APP/app/src/main/res/values-night/themes.xml b/Android-APP/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..9f7e713 --- /dev/null +++ b/Android-APP/app/src/main/res/values-night/themes.xml @@ -0,0 +1,5 @@ + + + +