From f650d0e85bb93cdc62c9c6c1e2629ece3f298e02 Mon Sep 17 00:00:00 2001 From: Umut Erdem Date: Tue, 7 Jul 2026 22:05:01 +0200 Subject: [PATCH] fix(ios): widget status derives from the issue list; readable light-mode green (#73, #74) Ships in the not-yet-archived 1.8.0 (build 33 unchanged, never uploaded). #73: the widget snapshot only knew idle/syncing/error, so a parked share or a required peer offline beyond its grace period left a green "All Synced" on the home screen - the same lie-class #66 fixed for the in-app header. The snapshot tier now comes from SyncHeaderModel.deriveWidgetStatus: the header's own cascade (decision 012) with the vault-setup tiers pinned "armed", fed by the same unresolvedIssues list plus the freshly polled folder statuses (they arrive before folderStatuses is published). The private WidgetSnapshotStatus enum is gone; the snapshot persists SyncStatus.wireValue. Wire compatibility holds both ways: a stale "idle" snapshot still decodes to synced, and fromWire maps anything unknown to attention, so no combination can decode into a false green. Two visible tier changes, both deliberate and matching the header: folder errors now show amber "Needs Attention" (was red "Sync Error"), and a snapshot written while the engine is down shows "Starting" (was green idle). The new suite pins every tier, asserts widget == header across the full input matrix, and round-trips the wire encoding. #74: light-mode statusSuccess measured 3.38:1 on white (WCAG wants 4.5:1 for text) and colors the "N of M devices connected" line; now (26,120,78) = 5.47:1, with the Increase Contrast variant deepened to (17,95,60) = 7.70:1 so it still increases contrast over the darker base. Dark mode unchanged. Same class and remedy as #68's amber. No strings added (the widget's "Needs Attention" labels shipped with #66). No Go changes - the earlier-1.8.0 xcframework rebuild note still applies before archiving. Verified: full Xcode test plan green (iPhone 17 Pro simulator, 237/237 incl. the new #73 suite), design-token lint green, Localizable key counts identical (632 x 4 app, 16 x 4 widget), WCAG ratios computed for old and new values. Fixes #73 Fixes #74 --- CHANGELOG.md | 2 + ios/VaultSync/Resources/Theme.swift | 8 +- ios/VaultSync/Services/SyncthingManager.swift | 41 +++--- .../ViewModels/SyncHeaderModel.swift | 28 ++++ .../WidgetSnapshotStatusTests.swift | 122 ++++++++++++++++++ 5 files changed, 181 insertions(+), 20 deletions(-) create mode 100644 ios/VaultSyncTests/WidgetSnapshotStatusTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e10d8..36fcb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,9 @@ All notable changes to VaultSync are documented here. ### Fixed +- **The home-screen widget no longer shows a green check while something needs your attention** ([#73](https://github.com/psimaker/vaultsync/issues/73)) — the widget only knew "all good", "syncing", and "error", so a share waiting for your decision or a required device offline beyond its grace period left the green check standing. The widget status now derives from the same issue list as the in-app header ([#66](https://github.com/psimaker/vaultsync/issues/66)) and shows "Needs Attention" the moment something waits for you. - **Warning colors are readable in light mode** ([#68](https://github.com/psimaker/vaultsync/issues/68)) — the amber used for warnings and failure explanations was too faint on white backgrounds (below the WCAG contrast minimum); it is now a darker amber that clears it, and all status colors gained dedicated high-contrast variants, so the iOS "Increase Contrast" setting now actually increases their contrast. +- **The green status color is readable in light mode too** ([#74](https://github.com/psimaker/vaultsync/issues/74)) — same class as the amber above: the light-mode green coloring the "devices connected" line was below the WCAG contrast minimum (3.38:1); it is now a darker green (5.47:1) with a deeper high-contrast variant, so "Increase Contrast" keeps having an effect. Dark mode is unchanged. - **Onboarding titles no longer truncate at large accessibility text sizes** ([#67](https://github.com/psimaker/vaultsync/issues/67)) — with very large Dynamic Type (German and Spanish especially), step titles cut off mid-word ("Deinen Obsidian-O…") instead of wrapping. - **Small wording and VoiceOver polish** ([#71](https://github.com/psimaker/vaultsync/issues/71)) — a parked share's button now says "Review and Accept" (it used to say "Retry Accept" although the user never attempted anything); the issue-list button drops the "First" qualifier when there is only one pending share; a fully synced vault shows "Up to Date" instead of the contradictory-sounding "Idle"; generic "Error" alert titles are replaced with descriptive ones; and the unreachable-vault card reads as one coherent VoiceOver element. Localized in English, German, Spanish, and Simplified Chinese. - **A share can no longer sync into a folder that already contains other files without asking** ([#54](https://github.com/psimaker/vaultsync/issues/54)) — if a folder under your Obsidian directory happened to have the same name as an incoming share (or the folder selected for VaultSync was itself a vault), the share was accepted straight into it — silently combining two sets of files and syncing the result to your other devices. VaultSync now stops before that happens: the share waits under "Pending Shares" with an explanation, and only you can approve the merge — in a dialog that spells out exactly what will be combined and where it will be synced. Approving is right when the folder holds this same vault's earlier notes (for example after removing the vault and accepting it again); for anything else, "Choose Vault…" picks a safe location. Enforced in the sync engine itself as well, so no code path can merge silently. Localized in English, German, Spanish, and Simplified Chinese. diff --git a/ios/VaultSync/Resources/Theme.swift b/ios/VaultSync/Resources/Theme.swift index 145a338..e78c9e3 100644 --- a/ios/VaultSync/Resources/Theme.swift +++ b/ios/VaultSync/Resources/Theme.swift @@ -148,9 +148,13 @@ extension Font { extension Color { /// Idle / all-synced / connected. + /// Light green is #1A784E (5.47:1 on white) — the previous (46, 158, 107) + /// measured 3.38:1 and colors the device-count text (#74, same class as + /// #68's amber). The HC variant deepens to 7.70:1 so Increase Contrast + /// still increases it. static let statusSuccess = vaultColor( - light: (46, 158, 107), dark: (52, 199, 127), - lightHC: (23, 117, 76), darkHC: (94, 222, 158) + light: (26, 120, 78), dark: (52, 199, 127), + lightHC: (17, 95, 60), darkHC: (94, 222, 158) ) /// Active transfer in progress (alias of the brand accent). static let statusSyncing = Color.vaultAccent diff --git a/ios/VaultSync/Services/SyncthingManager.swift b/ios/VaultSync/Services/SyncthingManager.swift index ffba11e..da3340b 100644 --- a/ios/VaultSync/Services/SyncthingManager.swift +++ b/ios/VaultSync/Services/SyncthingManager.swift @@ -1834,12 +1834,6 @@ final class SyncthingManager { return folders } - private enum WidgetSnapshotStatus: String { - case idle - case syncing - case error - } - private func updateWidgetSyncMetrics( previousStatuses: [String: FolderStatusInfo], newStatuses: [String: FolderStatusInfo] @@ -1864,7 +1858,7 @@ final class SyncthingManager { } private func completeWidgetSyncSession( - status: WidgetSnapshotStatus, + status: SyncStatus, completedAt: Date ) { let startedAt = activeWidgetSyncStart ?? completedAt @@ -1876,28 +1870,39 @@ final class SyncthingManager { writeWidgetSnapshotIfNeeded(statusOverride: status) } + /// Status tier persisted for the widget. Derives from the SAME issue list + /// the dashboard header and the "Sync Issues" section render (#73, + /// decision 012) — before this, the widget only knew idle/syncing/error + /// and kept a green check while a share was parked or a required peer was + /// offline. Freshly polled statuses arrive via `using:` because this runs + /// before `folderStatuses` is published, so a folder error in them is not + /// yet visible to `unresolvedIssues`. private func currentWidgetSnapshotStatus( using statuses: [String: FolderStatusInfo]? = nil - ) -> WidgetSnapshotStatus { + ) -> SyncStatus { let statuses = statuses ?? folderStatuses - if error != nil || userError != nil || statuses.values.contains(where: { $0.state == "error" }) { - return .error + var severities = unresolvedIssues.map(\.severity) + if statuses.values.contains(where: { $0.state == "error" }) { + severities.append(.critical) } - if activeWidgetSyncStart != nil || - statuses.values.contains(where: { $0.state == "syncing" || $0.state == "scanning" }) { - return .syncing - } - - return .idle + return SyncHeaderModel.deriveWidgetStatus( + hasEngineError: error != nil || userError != nil, + engineRunning: isRunning, + issueSeverities: severities, + hasUnreachableFolders: !unreachableFolders.isEmpty, + isSyncing: activeWidgetSyncStart != nil || + statuses.values.contains(where: { $0.state == "syncing" || $0.state == "scanning" }), + hasSyncFolders: !folders.isEmpty + ) } - private func writeWidgetSnapshotIfNeeded(statusOverride: WidgetSnapshotStatus? = nil) { + private func writeWidgetSnapshotIfNeeded(statusOverride: SyncStatus? = nil) { let snapshot = WidgetSnapshotStore.Snapshot( lastSyncTime: WidgetSnapshotStore.iso8601String(from: lastWidgetSyncCompletionTime ?? lastSyncTime), lastSyncDuration: activeWidgetSyncStart == nil ? lastWidgetSyncDuration : 0, - status: (statusOverride ?? currentWidgetSnapshotStatus()).rawValue, + status: (statusOverride ?? currentWidgetSnapshotStatus()).wireValue, filesSynced: activeWidgetSyncStart == nil ? lastWidgetSyncFilesSynced : activeWidgetSyncFilesSynced, folderCount: folders.count ) diff --git a/ios/VaultSync/ViewModels/SyncHeaderModel.swift b/ios/VaultSync/ViewModels/SyncHeaderModel.swift index c74857d..92eeef7 100644 --- a/ios/VaultSync/ViewModels/SyncHeaderModel.swift +++ b/ios/VaultSync/ViewModels/SyncHeaderModel.swift @@ -79,4 +79,32 @@ enum SyncHeaderModel { // one in Obsidian. A calm waiting state, never a green check. return State(status: .starting, titleKey: "No Vaults Yet") } + + /// Widget-snapshot tier (#73). The widget carries no vault-setup surface, + /// so the vault tiers are pinned "armed" and the cascade reduces to the + /// engine / issue / transfer tiers — but it IS the same cascade above + /// (decision 012), so an issue kind that reaches the header can never + /// miss the widget again. Before this, the widget only knew + /// idle/syncing/error and kept a green check while a share sat parked or + /// a required peer was offline. + static func deriveWidgetStatus( + hasEngineError: Bool, + engineRunning: Bool, + issueSeverities: [SyncthingManager.SyncIssueSeverity], + hasUnreachableFolders: Bool, + isSyncing: Bool, + hasSyncFolders: Bool + ) -> SyncStatus { + derive(.init( + hasEngineError: hasEngineError, + engineRunning: engineRunning, + issueSeverities: issueSeverities, + hasUnreachableFolders: hasUnreachableFolders, + isSyncing: isSyncing, + hasSyncFolders: hasSyncFolders, + vaultAccessible: true, + vaultNeedsReconnect: false, + hasDetectedVaults: true + )).status + } } diff --git a/ios/VaultSyncTests/WidgetSnapshotStatusTests.swift b/ios/VaultSyncTests/WidgetSnapshotStatusTests.swift new file mode 100644 index 0000000..c6af853 --- /dev/null +++ b/ios/VaultSyncTests/WidgetSnapshotStatusTests.swift @@ -0,0 +1,122 @@ +import Testing +@testable import VaultSync + +@Suite("Widget snapshot status derives from the issue list (#73)") +struct WidgetSnapshotStatusTests { + /// Baseline: engine healthy, folders configured, nothing wrong. + private func derive( + hasEngineError: Bool = false, + engineRunning: Bool = true, + issueSeverities: [SyncthingManager.SyncIssueSeverity] = [], + hasUnreachableFolders: Bool = false, + isSyncing: Bool = false, + hasSyncFolders: Bool = true + ) -> SyncStatus { + SyncHeaderModel.deriveWidgetStatus( + hasEngineError: hasEngineError, + engineRunning: engineRunning, + issueSeverities: issueSeverities, + hasUnreachableFolders: hasUnreachableFolders, + isSyncing: isSyncing, + hasSyncFolders: hasSyncFolders + ) + } + + // The reported lie: the widget kept a green check while a share sat + // parked or a required peer was offline — both warning-tier issues that + // the pre-#73 snapshot (idle/syncing/error only) could not express. + @Test("A warning-tier issue surfaces as attention, never as synced") + func warningTierReachesWidget() { + let status = derive(issueSeverities: [.warning]) + #expect(status == .attention) + #expect(status != .synced) + } + + @Test("Critical issues surface as attention") + func criticalTierReachesWidget() { + #expect(derive(issueSeverities: [.critical]) == .attention) + #expect(derive(issueSeverities: [.warning, .critical]) == .attention) + } + + @Test("Unreachable folders surface as attention") + func unreachableFoldersReachWidget() { + #expect(derive(hasUnreachableFolders: true) == .attention) + } + + @Test("Engine errors keep the error tier") + func engineErrorStaysError() { + #expect(derive(hasEngineError: true) == .error) + #expect(derive(hasEngineError: true, issueSeverities: [.critical]) == .error) + } + + // Same deliberate precedence as the header: a transfer is short-lived and + // the warning surfaces the moment it settles. + @Test("Active transfer outranks warnings, matching the header") + func syncingOutranksWarnings() { + #expect(derive(issueSeverities: [.warning], isSyncing: true) == .syncing) + } + + @Test("Engine not running maps to starting, never to synced") + func notRunningIsStarting() { + #expect(derive(engineRunning: false) == .starting) + } + + @Test("Clean state stays synced") + func cleanStateIsSynced() { + #expect(derive() == .synced) + #expect(derive(hasSyncFolders: false) == .synced) + } + + // Structural guarantee of decision 012: the widget tier IS the header + // cascade with the vault tiers pinned "armed" — a new issue kind that + // reaches the header can never miss the widget. + @Test("Widget tier equals the header cascade for every input combination") + func matchesHeaderCascade() { + let severityMatrix: [[SyncthingManager.SyncIssueSeverity]] = + [[], [.warning], [.critical], [.warning, .critical]] + for hasEngineError in [false, true] { + for engineRunning in [false, true] { + for severities in severityMatrix { + for hasUnreachableFolders in [false, true] { + for isSyncing in [false, true] { + for hasSyncFolders in [false, true] { + let widget = SyncHeaderModel.deriveWidgetStatus( + hasEngineError: hasEngineError, + engineRunning: engineRunning, + issueSeverities: severities, + hasUnreachableFolders: hasUnreachableFolders, + isSyncing: isSyncing, + hasSyncFolders: hasSyncFolders + ) + let header = SyncHeaderModel.derive(.init( + hasEngineError: hasEngineError, + engineRunning: engineRunning, + issueSeverities: severities, + hasUnreachableFolders: hasUnreachableFolders, + isSyncing: isSyncing, + hasSyncFolders: hasSyncFolders, + vaultAccessible: true, + vaultNeedsReconnect: false, + hasDetectedVaults: true + )).status + #expect(widget == header) + } + } + } + } + } + } + } + + // End-to-end wire trip: the tier the app persists must decode in the + // widget to the same tier — attention can never round-trip into the + // green branch. + @Test("Persisted wire value decodes back to the same tier in the widget") + func wireRoundTrip() { + let parkedShare = derive(issueSeverities: [.warning]) + #expect(SyncStatus.fromWire(parkedShare.wireValue) == .attention) + + let clean = derive() + #expect(SyncStatus.fromWire(clean.wireValue) == .synced) + } +}