Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions ios/VaultSync/Resources/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 23 additions & 18 deletions ios/VaultSync/Services/SyncthingManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -1864,7 +1858,7 @@ final class SyncthingManager {
}

private func completeWidgetSyncSession(
status: WidgetSnapshotStatus,
status: SyncStatus,
completedAt: Date
) {
let startedAt = activeWidgetSyncStart ?? completedAt
Expand All @@ -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
)
Expand Down
28 changes: 28 additions & 0 deletions ios/VaultSync/ViewModels/SyncHeaderModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
122 changes: 122 additions & 0 deletions ios/VaultSyncTests/WidgetSnapshotStatusTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading