Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.animation.core.tween
import androidx.compose.animation.animateContentSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
Expand All @@ -44,6 +46,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -108,7 +113,7 @@ class MainActivity : ComponentActivity() {
}.start()
}
setContent {
MaterialTheme {
SaveSyncTheme {
SaveSyncDashboard()
}
}
Expand Down Expand Up @@ -195,6 +200,16 @@ class MainActivity : ComponentActivity() {
val scope = rememberCoroutineScope()
val serverEndpointFocusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val uiPresentation = SaveSyncUiStatePresentation.from(
phase = syncPhase,
error = syncError,
pendingUploads = pendingUploads,
conflictCount = conflictReport?.branches?.size ?: 0,
sessionActive = sessionActive,
authorized = authorized,
gameEnabled = gameEnabled,
serverConfigured = serverEndpoint.isNotBlank(),
)

fun refreshConflicts() {
conflictVisible = true
Expand Down Expand Up @@ -616,8 +631,8 @@ class MainActivity : ComponentActivity() {
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
.padding(SaveSyncDesignTokens.screenPadding),
verticalArrangement = Arrangement.spacedBy(SaveSyncDesignTokens.sectionGap),
) {
Row(
modifier = Modifier.fillMaxWidth(),
Expand All @@ -634,17 +649,23 @@ class MainActivity : ComponentActivity() {

CardSection("存档状态") {
Text(
DashboardContentPolicy.status(
authorized,
gameEnabled,
serverEndpoint.isNotBlank(),
sessionActive,
),
uiPresentation.status,
style = MaterialTheme.typography.titleMedium,
)
Text(syncPhase, color = MaterialTheme.colorScheme.primary)
if (syncError.isNotBlank()) {
Text(syncError, color = MaterialTheme.colorScheme.error)
Text(
uiPresentation.nextAction,
color = when (uiPresentation.tone) {
SaveSyncUiTone.Success -> SaveSyncDesignTokens.success
SaveSyncUiTone.Warning -> SaveSyncDesignTokens.warning
SaveSyncUiTone.Error -> MaterialTheme.colorScheme.error
SaveSyncUiTone.Neutral -> MaterialTheme.colorScheme.primary
},
modifier = Modifier
.animateContentSize(animationSpec = tween(SaveSyncDesignTokens.contentMotionMillis))
.semantics { liveRegion = LiveRegionMode.Polite },
)
if (syncPhase.isNotBlank() && syncPhase != uiPresentation.status) {
Text(syncPhase, style = MaterialTheme.typography.labelMedium)
}
OutlinedButton(
enabled = serverEndpoint.isNotBlank() && hasRecoverySecret && !conflictLoading,
Expand Down Expand Up @@ -849,10 +870,14 @@ class MainActivity : ComponentActivity() {

@Composable
private fun CardSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Card(modifier = Modifier.fillMaxWidth()) {
Card(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = tween(SaveSyncDesignTokens.contentMotionMillis)),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(SaveSyncDesignTokens.contentGap),
) {
Text(title, style = MaterialTheme.typography.titleMedium)
HorizontalDivider()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.mhtoolkit.savesync

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

/** Shared semantic values for the Save Sync surfaces. Keep workflow meaning out of raw colors. */
object SaveSyncDesignTokens {
const val contractVersion = "apple-design-v1"
val screenPadding = 20.dp
val sectionGap = 14.dp
val contentGap = 10.dp
val compactGap = 8.dp
const val statusMotionMillis = 180
const val contentMotionMillis = 240

val success = Color(0xFF1B7F4B)
val warning = Color(0xFF9A6700)
val error = Color(0xFFBA1A1A)
}

private val LightSaveSyncColors = lightColorScheme(
primary = Color(0xFF0061A4),
onPrimary = Color.White,
secondary = Color(0xFF4F616E),
tertiary = Color(0xFF625A7D),
error = SaveSyncDesignTokens.error,
)

private val DarkSaveSyncColors = darkColorScheme(
primary = Color(0xFF9DCAFF),
secondary = Color(0xFFB7C9D6),
tertiary = Color(0xFFD0C4F4),
error = Color(0xFFFFB4AB),
)

@Composable
fun SaveSyncTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) DarkSaveSyncColors else LightSaveSyncColors,
content = content,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package org.mhtoolkit.savesync

enum class SaveSyncUiTone {
Success,
Neutral,
Warning,
Error,
}

data class SaveSyncUiStatePresentation(
val tone: SaveSyncUiTone,
val status: String,
val nextAction: String,
val isBlocking: Boolean,
) {
companion object {
fun from(
phase: String,
error: String,
pendingUploads: Int,
conflictCount: Int,
sessionActive: Boolean,
authorized: Boolean = true,
gameEnabled: Boolean = true,
serverConfigured: Boolean = true,
): SaveSyncUiStatePresentation {
if (error.isNotBlank() || phase.contains("失败")) {
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Error,
status = "同步未完成",
nextAction = "检查网络、密钥和目录授权后重试;本地与云端均未被静默覆盖",
isBlocking = true,
)
}
if (conflictCount > 0 || phase == "等待确认") {
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Warning,
status = if (conflictCount > 0) "$conflictCount 个冲突待处理" else "等待确认",
nextAction = "选择上传本地或恢复云端;不会自动覆盖",
isBlocking = true,
)
}
if (pendingUploads > 0 || phase.contains("排队")) {
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Neutral,
status = "$pendingUploads 项等待网络恢复后续传",
nextAction = "保持网络可用;队列会按原服务器地址继续,不会静默覆盖",
isBlocking = false,
)
}
if (!gameEnabled) {
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Neutral,
status = "MH3G 同步已暂停",
nextAction = "重新打开同步后仍会保留历史版本与显式冲突选择",
isBlocking = true,
)
}
if (!authorized) {
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Warning,
status = "需要设置存档目录",
nextAction = "选择 Android Nemessix 存档目录;授权不会立即上传或覆盖",
isBlocking = true,
)
}
if (!serverConfigured) {
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Warning,
status = "需要设置服务器",
nextAction = "填写和 Mac 相同的服务器地址;未填写前不会上传",
isBlocking = true,
)
}
if (sessionActive) {
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Success,
status = "游玩中 · 本地已保护",
nextAction = "退出 Nemessix 后再对账上传稳定快照",
isBlocking = false,
)
}
return SaveSyncUiStatePresentation(
tone = SaveSyncUiTone.Success,
status = "可以同步",
nextAction = "启动前检查云端;需要替换时会先备份并确认",
isBlocking = false,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.mhtoolkit.savesync

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class SaveSyncDesignContractTest {
@Test
fun `design contract exposes semantic version and bounded response`() {
assertEquals("apple-design-v1", SaveSyncDesignTokens.contractVersion)
assertTrue(SaveSyncDesignTokens.statusMotionMillis in 120..240)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.mhtoolkit.savesync

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class SaveSyncUiStateTest {
@Test
fun `conflict state is announced as blocking and keeps an explicit next action`() {
val presentation = SaveSyncUiStatePresentation.from(
phase = "等待确认",
error = "",
pendingUploads = 0,
conflictCount = 2,
sessionActive = false,
)

assertEquals(SaveSyncUiTone.Warning, presentation.tone)
assertTrue(presentation.isBlocking)
assertEquals("选择上传本地或恢复云端;不会自动覆盖", presentation.nextAction)
}

@Test
fun `offline queue state explains that work is retained`() {
val presentation = SaveSyncUiStatePresentation.from(
phase = "上传排队中",
error = "",
pendingUploads = 3,
conflictCount = 0,
sessionActive = false,
)

assertEquals(SaveSyncUiTone.Neutral, presentation.tone)
assertEquals("3 项等待网络恢复后续传", presentation.status)
assertTrue(presentation.nextAction.contains("不会静默覆盖"))
}

@Test
fun `error state is actionable without claiming data was changed`() {
val presentation = SaveSyncUiStatePresentation.from(
phase = "上传失败",
error = "同步失败",
pendingUploads = 1,
conflictCount = 0,
sessionActive = false,
)

assertEquals(SaveSyncUiTone.Error, presentation.tone)
assertTrue(presentation.isBlocking)
assertEquals("检查网络、密钥和目录授权后重试;本地与云端均未被静默覆盖", presentation.nextAction)
}
}
15 changes: 14 additions & 1 deletion apps/macos/Sources/MHSaveSyncMac/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,12 @@ final class MenuController: NSObject, NSApplicationDelegate {
item.button?.imagePosition = .imageLeading
}
item.button?.title = context.menuBarTitle
item.button?.setAccessibilityLabel(
statusItemAccessibilityLabel(menuBarTitle: context.menuBarTitle)
)
item.button?.setAccessibilityHelp(
statusItemAccessibilityHelp(nextAction: nextActionText(context))
)
item.button?.toolTip = "MH 云存档同步 · macOS Alpha"
let menu = NSMenu()
let routeItem = NSMenuItem(title: "MH3G · macOS Nemessix", action: nil, keyEquivalent: "")
Expand Down Expand Up @@ -989,7 +995,14 @@ final class MenuController: NSObject, NSApplicationDelegate {

private func refreshMenuLabels() {
statusItem?.button?.title = context.menuBarTitle
statusItem?.button?.toolTip = "下一步:\(nextActionText(context))"
let nextAction = nextActionText(context)
statusItem?.button?.setAccessibilityLabel(
statusItemAccessibilityLabel(menuBarTitle: context.menuBarTitle)
)
statusItem?.button?.setAccessibilityHelp(
statusItemAccessibilityHelp(nextAction: nextAction)
)
statusItem?.button?.toolTip = "下一步:\(nextAction)"
routeMenuItem?.title = context.onboardingComplete ? "MH3G · 配置完成" : "MH3G · 需要完成设置"
autoUploadMenuItem?.title = "自动同步:\(context.autoUploadOnExit ? "退出后自动上传" : "关闭")"
autoUploadMenuItem?.state = context.autoUploadOnExit ? .on : .off
Expand Down
15 changes: 15 additions & 0 deletions apps/macos/Sources/MacPresentation/SyncResultPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ public enum MenuCopy {
public static let cloudStatus = "云端状态"
}

/// Stable semantic labels for the menu-bar status item. The status item is
/// hosted by macOS Control Center, so an explicit label/help pair is needed
/// for VoiceOver and Accessibility Inspector to identify it reliably.
public func statusItemAccessibilityLabel(menuBarTitle: String) -> String {
let prefix = "MH 云存档 · "
let state = menuBarTitle.hasPrefix(prefix)
? String(menuBarTitle.dropFirst(prefix.count))
: menuBarTitle
return "MH 云存档 · \(state)"
}

public func statusItemAccessibilityHelp(nextAction: String) -> String {
"下一步:\(nextAction)"
}

public func onboardingPrompt(
missingServer: Bool,
missingSaveRoot: Bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ import Testing
#expect(MenuCopy.cloudStatus == "云端状态")
}

@Test func statusItemAccessibilityNamesTheCurrentStateAndNextAction() {
#expect(statusItemAccessibilityLabel(menuBarTitle: "MH 云存档 · 选目录") ==
"MH 云存档 · 选目录")
#expect(statusItemAccessibilityHelp(nextAction: "请选择 Nemessix 存档目录") ==
"下一步:请选择 Nemessix 存档目录")
}

@Test func onboardingPromptIsShortAndActionable() {
let text = onboardingPrompt(missingServer: true, missingSaveRoot: true, missingSecret: true)
#expect(text == "还差 3 项设置。先填写服务器地址。")
Expand Down
Loading
Loading