diff --git a/Project.swift b/Project.swift index b88a8e1e..7e3aba7e 100644 --- a/Project.swift +++ b/Project.swift @@ -15,9 +15,23 @@ private let stuffPackage = Package.local(path: .relativeToRoot(".")) /// Xcode falls back to its defaults. private let developmentTeam = Environment.developmentTeam.getString(default: "") -private let projectSettings: Settings? = developmentTeam.isEmpty - ? nil - : .settings(base: ["DEVELOPMENT_TEAM": .string(developmentTeam)]) +/// Base build settings applied to every Tuist-generated target. +/// +/// `STRING_CATALOG_GENERATE_SYMBOLS` turns on Xcode's type-safe String Catalog +/// symbol generation for the app and app-extension targets (Where, WhereWidgets, +/// WhereShareExtension, …). The SwiftPM package targets declared in `Package.swift` +/// (WhereUI, WhereCore, RegionKit, LifecycleKit) get symbol generation automatically +/// from the toolchain, so this only needs to reach the Tuist-native targets. +/// +/// `DEVELOPMENT_TEAM` is threaded in from the environment when present (see above). +private let projectSettings: Settings = .settings( + base: developmentTeam.isEmpty + ? ["STRING_CATALOG_GENERATE_SYMBOLS": "YES"] + : [ + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + "DEVELOPMENT_TEAM": .string(developmentTeam), + ], +) /// App Group shared by the Where app, its widget extension, and its share /// extension so every process sees the same on-disk SwiftData store (see diff --git a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift b/Shared/LifecycleKit/Sources/LifecycleFailureView.swift index fb5468af..005d20f9 100644 --- a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift +++ b/Shared/LifecycleKit/Sources/LifecycleFailureView.swift @@ -13,14 +13,11 @@ public struct LifecycleFailureView: View { public var body: some View { ContentUnavailableView { - Label( - String(localized: "failure.launch.title", bundle: .module), - systemImage: "exclamationmark.triangle", - ) + Label(.failureLaunchTitle, systemImage: "exclamationmark.triangle") } description: { Text(failure.error.localizedDescription) } actions: { - Button(String(localized: "failure.launch.retry", bundle: .module), action: retry) + Button(.failureLaunchRetry, action: retry) .buttonStyle(.borderedProminent) } } diff --git a/Where/AGENTS.md b/Where/AGENTS.md index bbcc12f1..feb59e95 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -115,26 +115,40 @@ Rules the code enforces and agents must preserve: ## Localization -All user-facing copy resolves through module string catalogs — no literals in -views or thrown errors. - -- **WhereUI:** funnel every string through `Strings.swift` (keys in the module - `Localizable.xcstrings`, `bundle: .module`). Counts use catalog plural - variations; years use a grouping-free number style ("2026", not "2,026"). -- **WhereCore:** user-visible errors use static - `String(localized:bundle: .module)` keys in its own catalog. -- **RegionKit:** region names (`Region.localizedName`) use static - `String(localized:bundle: .module)` keys in RegionKit's own catalog. +All user-facing copy resolves through module String Catalogs via Xcode's +generated `LocalizedStringResource` symbols — no string literals in views or +thrown errors, and no raw keys or `bundle:` arguments in code. Symbol generation +is on for every catalog (`STRING_CATALOG_GENERATE_SYMBOLS`, set project-wide for +the app and extensions in [`Project.swift`](../Project.swift); automatic for the +SwiftPM library targets), so a manual key `some.key` produces a `.someKey` +symbol and a removed or misspelled key is a compile error. + +- **Add the key first.** Add it to the module's `Localizable.xcstrings` as a + **manual** key (the topmost `+`) — that is what generates the symbol — then + reference the symbol, never a raw string key. +- **Reference symbols directly** at the call site: `Text(.tabPrimary)`, + `String(localized: .commonOk)`, `Label(.evidenceAdd, systemImage:)`. Pass the + bare symbol where SwiftUI takes a `LocalizedStringResource` (`Text`, `Label`, + `Button`); use `String(localized: .symbol)` where a `String` is needed + (notification bodies, `errorDescription`, interpolated accessibility labels). +- **WhereUI:** strings that need composition or a `switch` — pluralization, + grouping-free years ("2026", not "2,026"), region-name lists, enum-driven + copy, composed accessibility labels — go through + [`WhereFormat`](WhereUI/Sources/Shared/WhereFormat.swift), which builds them + from the generated symbols. Everything else is a symbol referenced inline. +- **WhereCore / RegionKit:** user-visible errors and region names + (`Region.localizedName`) switch over the generated symbols + (`String(localized: .regionCalifornia)`) — no `bundle: .module`. - **DEBUG-only UI** still gets catalog entries — don't bypass localization because a surface is dev-only. -- **WhereWidgets:** gallery name/description live in the extension's own - catalog; in-widget copy reuses WhereUI `Strings`. -- **WhereShareExtension:** compose-sheet chrome lives in the extension's own - catalog (`ShareStrings`); evidence kind names reuse WhereUI's public - `EvidenceKind` presentation helpers. - -Add the key to the catalog first, then reference it — never ship English -literals in SwiftUI `Text` or `errorDescription`. +- **WhereWidgets:** gallery name/description use the extension's own generated + symbols; in-widget copy comes from the shared WhereUI content views. +- **WhereShareExtension:** compose-sheet chrome uses the extension's own + generated symbols; evidence kind names reuse WhereUI's public `EvidenceKind` + presentation helpers. + +Add the manual key to the catalog first, then reference its symbol — never ship +English literals in SwiftUI `Text` or `errorDescription`. ## Dates & presentation diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index d03d6615..e499e6ed 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -52,16 +52,19 @@ print(region.localizedName) // "California" Region polygons ship in `Sources/Resources/*.geojson`; see [`Sources/Resources/README.md`](Sources/Resources/README.md) for provenance and fidelity notes. Region names resolve through RegionKit's own -`Localizable.xcstrings` (`Region.localizedName`, `bundle: .module`). +`Localizable.xcstrings` — `Region.localizedName` reads Xcode's generated +`LocalizedStringResource` symbols (`String(localized: .regionCalifornia)`), so +no bundle argument is needed. ## Adding a region Add the `Region` case, then resolve the two compile errors it forces: a -`region.` entry in `Sources/Resources/Localizable.xcstrings` (for -`localizedName`) and a `Region.geometrySource` case — either -`.usStateFeature(name:)` (a feature already in `us-states.geojson`, no new file) -or `.bundledFile` with a new `.geojson` in `Sources/Resources/`. Add a -`RegionAttributorTests` spot-check. +`region.` entry in `Sources/Resources/Localizable.xcstrings` (added as +a **manual** key, which generates the `.region` symbol `localizedName` +switches on) and a `Region.geometrySource` case — either `.usStateFeature(name:)` +(a feature already in `us-states.geojson`, no new file) or `.bundledFile` with a +new `.geojson` in `Sources/Resources/`. Add a `RegionAttributorTests` +spot-check. ## Testing diff --git a/Where/RegionKit/Sources/Region.swift b/Where/RegionKit/Sources/Region.swift index 0005e3b7..70e6fb8a 100644 --- a/Where/RegionKit/Sources/Region.swift +++ b/Where/RegionKit/Sources/Region.swift @@ -21,24 +21,23 @@ public enum Region: String, Codable, Sendable, Hashable, CaseIterable { /// User-facing name for this region, read from the `RegionKit` /// string catalog (`Resources/Localizable.xcstrings`). /// - /// Uses `String(localized:)` with a literal key per case (rather - /// than `NSLocalizedString` with a runtime-composed - /// `"region.\(rawValue)"`) so Xcode's string-catalog extraction - /// tooling can statically find every key. Adding a new region - /// case is intentionally a compile error here until you add a - /// matching string catalog entry. + /// Uses Xcode's generated `LocalizedStringResource` symbol per case + /// (rather than a runtime-composed `"region.\(rawValue)"` key) so the + /// compiler enforces that every case has a catalog entry: adding a new + /// region case is a compile error here until its manual key is added to + /// the catalog, which is what produces the symbol. public var localizedName: String { switch self { case .california: - String(localized: "region.california", bundle: .module) + String(localized: .regionCalifornia) case .newYork: - String(localized: "region.newYork", bundle: .module) + String(localized: .regionNewYork) case .canada: - String(localized: "region.canada", bundle: .module) + String(localized: .regionCanada) case .europeanUnion: - String(localized: "region.europeanUnion", bundle: .module) + String(localized: .regionEuropeanUnion) case .other: - String(localized: "region.other", bundle: .module) + String(localized: .regionOther) } } diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 7a2d3ecf..c56bae82 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -24,7 +24,9 @@ internal shape. - Library target in [`Package.swift`](../../Package.swift) (`Where/WhereCore/Sources`); depended on by `WhereUI` and the `WhereWidgets` extension. User-visible error strings ship in its own - `Sources/Resources/Localizable.xcstrings` (`bundle: .module`). + `Sources/Resources/Localizable.xcstrings` and resolve through Xcode's + generated symbols (`String(localized: .backupErrorManifestMissing)`) — no + `bundle: .module`. ## Shape & invariants diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 6295bfa9..96e45b7a 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -40,21 +40,9 @@ public struct BackupService: Sendable { public var errorDescription: String? { switch self { case .manifestMissing: - String( - localized: "backup.error.manifestMissing", - defaultValue: "This file isn't a Where backup (no manifest was found).", - bundle: .module, - ) + String(localized: .backupErrorManifestMissing) case let .unsupportedFormatVersion(version): - String( - format: String( - localized: "backup.error.unsupportedFormatVersion", - defaultValue: - "This backup was created by a newer version of Where (format %lld) and can't be imported.", - bundle: .module, - ), - version, - ) + String(localized: .backupErrorUnsupportedFormatVersion(version)) } } } diff --git a/Where/WhereCore/Sources/DailySummaryReconciler.swift b/Where/WhereCore/Sources/DailySummaryReconciler.swift index 51aaf24b..092f191a 100644 --- a/Where/WhereCore/Sources/DailySummaryReconciler.swift +++ b/Where/WhereCore/Sources/DailySummaryReconciler.swift @@ -86,7 +86,7 @@ public actor DailySummaryReconciler { .prefix(regionLimit) guard !ranked.isEmpty else { - return String(localized: "summary.notification.body.empty", bundle: .module) + return String(localized: .summaryNotificationBodyEmpty) } return ranked @@ -95,19 +95,10 @@ public actor DailySummaryReconciler { } private static func summaryFragment(region: Region, days: Int) -> String { - let count = String( - localized: "summary.notification.dayCount", - defaultValue: "\(days) days", - bundle: .module, - ) - return String( - format: String( - localized: "summary.notification.regionDays", - defaultValue: "%1$@ in %2$@", - bundle: .module, - ), - count, - region.localizedName, - ) + // Two type-safe symbols composed in place: the plural day count + // (".summaryNotificationDayCount") rendered into the " in " + // shape (".summaryNotificationRegionDays"), replacing the old String(format:). + let dayCount = String(localized: .summaryNotificationDayCount(days)) + return String(localized: .summaryNotificationRegionDays(dayCount, region.localizedName)) } } diff --git a/Where/WhereCore/Sources/DataIssueAlertReconciler.swift b/Where/WhereCore/Sources/DataIssueAlertReconciler.swift index 6c07771c..8db177dd 100644 --- a/Where/WhereCore/Sources/DataIssueAlertReconciler.swift +++ b/Where/WhereCore/Sources/DataIssueAlertReconciler.swift @@ -81,10 +81,6 @@ public actor DataIssueAlertReconciler { /// The alert body, pluralized on the issue count (e.g. "1 issue to resolve" /// / "3 issues to resolve"). private static func body(count: Int) -> String { - String( - localized: "dataIssues.notification.body", - defaultValue: "\(count) issues to resolve", - bundle: .module, - ) + String(localized: .dataIssuesNotificationBody(count)) } } diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift index ad1edda5..6809b41a 100644 --- a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift @@ -136,7 +136,7 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling components.minute = time.minute let content = UNMutableNotificationContent() - content.title = String(localized: "summary.notification.title", bundle: .module) + content.title = String(localized: .summaryNotificationTitle) content.body = body content.sound = .default diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift index 80fb41c3..31545e7e 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift @@ -151,7 +151,7 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu components.minute = time.minute let content = UNMutableNotificationContent() - content.title = String(localized: "dataIssues.notification.title", bundle: .module) + content.title = String(localized: .dataIssuesNotificationTitle) content.body = body content.sound = .default diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 27c334c3..54fa35fd 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -225,8 +225,8 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, components.minute = time.minute let content = UNMutableNotificationContent() - content.title = String(localized: "reminder.notification.title", bundle: .module) - content.body = String(localized: "reminder.notification.body", bundle: .module) + content.title = String(localized: .reminderNotificationTitle) + content.body = String(localized: .reminderNotificationBody) content.sound = .default let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index 798b7c16..aeb62f8a 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -14,7 +14,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature and **LogKit**. Embedded by the **Where** app; shares the `group.com.stuff.where` App Group entitlement. - Presentation reuses WhereUI's public `EvidenceKind.symbolName`/`displayName`; - only extension chrome lives in this target's `ShareStrings` + catalog. + only extension chrome lives in this target's own catalog, referenced through + Xcode's generated symbols (`String(localized: .shareTitle)`, `Text(.shareFormNoteHeader)`). - No test bundle; the write path is covered from **WhereCore** store tests and the **WhereUI** compose model. diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index 6333923f..58d3f15c 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -31,7 +31,8 @@ Host app Share sheet - **`ShareEvidenceView`** is the compose form; kind names/symbols reuse WhereUI's public `EvidenceKind` presentation helpers so they read identically to the in-app "Add evidence" sheet. Extension-only chrome resolves through - `ShareStrings` from this target's own catalog. + this target's own String Catalog via Xcode's generated symbols + (`String(localized: .shareTitle)`, `Text(.shareFormNoteHeader)`). ## Why write to the store directly diff --git a/Where/WhereShareExtension/Resources/Localizable.xcstrings b/Where/WhereShareExtension/Resources/Localizable.xcstrings index 045b4e95..0038e30f 100644 --- a/Where/WhereShareExtension/Resources/Localizable.xcstrings +++ b/Where/WhereShareExtension/Resources/Localizable.xcstrings @@ -3,6 +3,7 @@ "strings" : { "share.attachment.fallbackName" : { "comment" : "Stand-in name for a shared attachment the provider gave no filename for.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -14,6 +15,7 @@ }, "share.attachment.header" : { "comment" : "Section header above the shared attachment summary (one item).", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -25,6 +27,7 @@ }, "share.attachment.headerPlural" : { "comment" : "Section header above the shared attachments (more than one item).", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -36,6 +39,7 @@ }, "share.attachment.none" : { "comment" : "Shown when the share carried nothing loadable, so only metadata is saved.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -47,6 +51,7 @@ }, "share.cancel" : { "comment" : "Cancel button dismissing the share sheet without saving.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -58,6 +63,7 @@ }, "share.form.date" : { "comment" : "Date picker label for when the evidence was captured.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -69,6 +75,7 @@ }, "share.form.kind" : { "comment" : "Picker label for the evidence kind.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -80,6 +87,7 @@ }, "share.form.noteHeader" : { "comment" : "Section header above the optional note field.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -91,6 +99,7 @@ }, "share.form.notePlaceholder" : { "comment" : "Placeholder for the optional note text field.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -102,6 +111,7 @@ }, "share.form.otherLabel" : { "comment" : "Placeholder for the free-text label shown when the kind is Other.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -113,6 +123,7 @@ }, "share.loading" : { "comment" : "Progress label while the shared attachment is being read.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -124,6 +135,7 @@ }, "share.ok" : { "comment" : "Dismiss button on the save-failure alert.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -135,6 +147,7 @@ }, "share.save" : { "comment" : "Save button persisting the shared evidence.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -146,6 +159,7 @@ }, "share.saveError.title" : { "comment" : "Title of the alert shown when saving the shared evidence fails.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -157,6 +171,7 @@ }, "share.title" : { "comment" : "Navigation title of the share compose sheet.", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceView.swift b/Where/WhereShareExtension/Sources/ShareEvidenceView.swift index a2d488f4..f01bdadf 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceView.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceView.swift @@ -29,32 +29,32 @@ struct ShareEvidenceView: View { Group { switch model.phase { case .loading: - ProgressView(ShareStrings.loading) + ProgressView(String(localized: .shareLoading)) .frame(maxWidth: .infinity, maxHeight: .infinity) case .composing, .saving, .failed: form } } .animation(.default, value: model.phase) - .navigationTitle(ShareStrings.title) + .navigationTitle(String(localized: .shareTitle)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(ShareStrings.cancel) { onCancel() } + Button(.shareCancel) { onCancel() } } ToolbarItem(placement: .confirmationAction) { - Button(ShareStrings.save) { + Button(.shareSave) { Task { if await model.save() { onSave() } } } .disabled(model.isSaving || model.phase == .loading) } } .alert( - ShareStrings.saveErrorTitle, + String(localized: .shareSaveErrorTitle), isPresented: $model.isShowingSaveError, presenting: model.saveErrorMessage, ) { _ in - Button(ShareStrings.ok, role: .cancel) {} + Button(.shareOk, role: .cancel) {} } message: { message in Text(message) } @@ -68,25 +68,25 @@ struct ShareEvidenceView: View { return Form { attachmentSection Section { - Picker(ShareStrings.kindLabel, selection: $model.kind) { + Picker(String(localized: .shareFormKind), selection: $model.kind) { ForEach(EvidenceKind.knownCases, id: \.self) { kind in Label(kind.displayName, systemImage: kind.symbolName).tag(kind) } } if case .other = model.kind { - TextField(ShareStrings.otherLabelPlaceholder, text: $model.otherLabel) + TextField(String(localized: .shareFormOtherLabel), text: $model.otherLabel) } - DatePicker(ShareStrings.dateLabel, selection: $model.capturedAt) + DatePicker(String(localized: .shareFormDate), selection: $model.capturedAt) } Section { TextField( - ShareStrings.notePlaceholder, + String(localized: .shareFormNotePlaceholder), text: $model.note, axis: .vertical, ) .lineLimit(3, reservesSpace: true) } header: { - Text(ShareStrings.noteHeader) + Text(.shareFormNoteHeader) } } } @@ -94,7 +94,7 @@ struct ShareEvidenceView: View { private var attachmentSection: some View { Section { if model.attachments.isEmpty { - Text(ShareStrings.noAttachment) + Text(.shareAttachmentNone) .foregroundStyle(.secondary) } else { ForEach(Array(model.attachments.enumerated()), id: \.offset) { _, attachment in @@ -102,14 +102,15 @@ struct ShareEvidenceView: View { } } } header: { - Text(ShareStrings.attachmentHeader(count: model.attachments.count)) + Text(model.attachments + .count > 1 ? .shareAttachmentHeaderPlural : .shareAttachmentHeader) } } private func attachmentRow(_ attachment: SharedAttachment) -> some View { HStack { Label( - attachment.filename ?? ShareStrings.attachmentFallbackName, + attachment.filename ?? String(localized: .shareAttachmentFallbackName), systemImage: "paperclip", ) .lineLimit(1) diff --git a/Where/WhereShareExtension/Sources/ShareStrings.swift b/Where/WhereShareExtension/Sources/ShareStrings.swift deleted file mode 100644 index e34282fc..00000000 --- a/Where/WhereShareExtension/Sources/ShareStrings.swift +++ /dev/null @@ -1,92 +0,0 @@ -import Foundation - -/// Compose-sheet chrome resolved from this extension's own string catalog. -/// Evidence *kind* names and symbols come from WhereUI's `EvidenceKind` -/// presentation helpers so they read identically to the in-app form; only the -/// extension-specific copy lives here (mirroring `WidgetStrings`). -enum ShareStrings { - static var title: String { - String(localized: "share.title", defaultValue: "Save to Where", bundle: .module) - } - - static var loading: String { - String(localized: "share.loading", defaultValue: "Preparing…", bundle: .module) - } - - static var save: String { - String(localized: "share.save", defaultValue: "Save", bundle: .module) - } - - static var cancel: String { - String(localized: "share.cancel", defaultValue: "Cancel", bundle: .module) - } - - static var ok: String { - String(localized: "share.ok", defaultValue: "OK", bundle: .module) - } - - /// Section header above the shared attachments — singular for one item, - /// plural for several (a multi-item share captures one record each). - static func attachmentHeader(count: Int) -> String { - count > 1 - ? String( - localized: "share.attachment.headerPlural", - defaultValue: "Attachments", - bundle: .module, - ) - : String( - localized: "share.attachment.header", - defaultValue: "Attachment", - bundle: .module, - ) - } - - /// Stand-in name for an attachment the provider gave no filename for. - static var attachmentFallbackName: String { - String( - localized: "share.attachment.fallbackName", - defaultValue: "Attachment", - bundle: .module, - ) - } - - static var noAttachment: String { - String( - localized: "share.attachment.none", - defaultValue: "No attachment — this will save a note only.", - bundle: .module, - ) - } - - static var kindLabel: String { - String(localized: "share.form.kind", defaultValue: "Kind", bundle: .module) - } - - static var otherLabelPlaceholder: String { - String(localized: "share.form.otherLabel", defaultValue: "Label", bundle: .module) - } - - static var dateLabel: String { - String(localized: "share.form.date", defaultValue: "Date", bundle: .module) - } - - static var noteHeader: String { - String(localized: "share.form.noteHeader", defaultValue: "Note", bundle: .module) - } - - static var notePlaceholder: String { - String( - localized: "share.form.notePlaceholder", - defaultValue: "Add a note (optional)", - bundle: .module, - ) - } - - static var saveErrorTitle: String { - String( - localized: "share.saveError.title", - defaultValue: "Couldn't save", - bundle: .module, - ) - } -} diff --git a/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift b/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift index a508d63e..ae809809 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperOverlay.swift @@ -182,7 +182,7 @@ Image(systemName: "xmark.circle.fill") .symbolRenderingMode(.hierarchical) } - .accessibilityLabel(Strings.developerClose) + .accessibilityLabel(String(localized: .developerClose)) Spacer() @@ -191,8 +191,8 @@ ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right") } - .accessibilityLabel(isFullScreen ? Strings.developerCollapse : Strings - .developerExpand) + .accessibilityLabel(isFullScreen ? String(localized: .developerCollapse) : + String(localized: .developerExpand)) } .font(.title3) .buttonStyle(.plain) diff --git a/Where/WhereUI/Sources/Developer/DeveloperOverlayButton.swift b/Where/WhereUI/Sources/Developer/DeveloperOverlayButton.swift index ddb34334..59a837b5 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperOverlayButton.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperOverlayButton.swift @@ -25,7 +25,7 @@ .overlay(Circle().strokeBorder(.secondary.opacity(0.5), lineWidth: 1.5)) .contentShape(Circle()) .shadow(color: .black.opacity(0.15), radius: 2, y: 1) - .accessibilityLabel(Strings.developerButtonLabel) + .accessibilityLabel(String(localized: .developerButtonLabel)) .accessibilityAddTraits(.isButton) } } diff --git a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift index 6f8b838f..555b50d0 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift @@ -27,10 +27,10 @@ NavigationLink { LogViewer(configuration: LogViewerConfiguration( stores: [WhereLog.store, RegionLog.store], - title: Strings.developerLogsTitle, + title: String(localized: .developerLogsTitle), )) } label: { - Label(Strings.developerLogsLink, systemImage: "ladybug") + Label(.developerLogsLink, systemImage: "ladybug") } if let configuration = session?.swiftDataInspectorConfiguration { @@ -38,7 +38,7 @@ SwiftDataInspectorView(configuration: configuration) } label: { Label( - Strings.developerInspectorLink, + .developerInspectorLink, systemImage: "cylinder.split.1x2", ) } @@ -47,13 +47,13 @@ NavigationLink { RegionMapView() } label: { - Label(Strings.developerRegionMapLink, systemImage: "map") + Label(.developerRegionMapLink, systemImage: "map") } } footer: { - Text(Strings.developerFooter) + Text(.developerFooter) } } - .navigationTitle(Strings.developerTitle) + .navigationTitle(String(localized: .developerTitle)) .navigationBarTitleDisplayMode(.inline) } } diff --git a/Where/WhereUI/Sources/Developer/RegionMapView.swift b/Where/WhereUI/Sources/Developer/RegionMapView.swift index f50f4242..74cbd704 100644 --- a/Where/WhereUI/Sources/Developer/RegionMapView.swift +++ b/Where/WhereUI/Sources/Developer/RegionMapView.swift @@ -35,15 +35,15 @@ public struct RegionMapView: View { kindPicker stateContent } - .navigationTitle(Strings.regionMapTitle) + .navigationTitle(String(localized: .regionMapTitle)) .navigationBarTitleDisplayMode(.inline) .task(id: kind) { await load() } } private var kindPicker: some View { - Picker(Strings.regionMapKindPicker, selection: $kind) { + Picker(String(localized: .regionMapKindPicker), selection: $kind) { ForEach(RegionGeometryKind.allCases, id: \.self) { kind in - Text(Strings.regionMapKind(kind)).tag(kind) + Text(WhereFormat.regionMapKind(kind)).tag(kind) } } .pickerStyle(.segmented) @@ -58,15 +58,15 @@ public struct RegionMapView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) case let .some(.failure(error)): ContentUnavailableView( - Strings.regionMapLoadErrorTitle, + String(localized: .regionMapLoadErrorTitle), systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription), ) case let .some(.success(loaded)) where loaded.isEmpty: ContentUnavailableView( - Strings.regionMapEmptyTitle, + String(localized: .regionMapEmptyTitle), systemImage: "map", - description: Text(Strings.regionMapEmptyDescription), + description: Text(.regionMapEmptyDescription), ) case let .some(.success(loaded)): VStack(spacing: 0) { @@ -86,14 +86,14 @@ public struct RegionMapView: View { } .mapStyle(.standard(pointsOfInterest: .excludingAll)) .frame(maxHeight: .infinity) - .accessibilityLabel(Strings.regionMapMapAccessibility) + .accessibilityLabel(String(localized: .regionMapMapAccessibility)) } private func legend(for loaded: [RegionOutline]) -> some View { List { Section { if selectedTitle != nil { - Button(Strings.regionMapShowAll) { select(nil) } + Button(.regionMapShowAll) { select(nil) } } ForEach(legendGroups(for: loaded)) { group in Button { @@ -104,9 +104,9 @@ public struct RegionMapView: View { .tint(.primary) } } header: { - Text(Strings.regionMapLegendHeader) + Text(.regionMapLegendHeader) } footer: { - Text(Strings.regionMapKindFooter(kind)) + Text(WhereFormat.regionMapKindFooter(kind)) } } .frame(height: stylesheet.regionMap.height) diff --git a/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift b/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift index 2b94b0da..c4d762a8 100644 --- a/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift +++ b/Where/WhereUI/Sources/Evidence/AddEvidenceView.swift @@ -38,14 +38,14 @@ struct AddEvidenceView: View { detailsSection noteSection } - .navigationTitle(Strings.evidenceAdd) + .navigationTitle(String(localized: .evidenceAdd)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(Strings.commonCancel) { dismiss() } + Button(.commonCancel) { dismiss() } } ToolbarItem(placement: .confirmationAction) { - Button(Strings.evidenceSave) { + Button(.evidenceFormSave) { Task { if await model.save() { dismiss() } } } .disabled(model.isSaving) @@ -58,20 +58,20 @@ struct AddEvidenceView: View { ) .onChange(of: photoItem) { _, item in loadPhoto(item) } .alert( - Strings.evidenceSaveErrorTitle, + String(localized: .evidenceFormSaveErrorTitle), isPresented: $model.isShowingSaveError, presenting: model.saveErrorMessage, ) { _ in - Button(Strings.commonOK, role: .cancel) {} + Button(.commonOk, role: .cancel) {} } message: { message in Text(message) } .alert( - Strings.evidenceSaveErrorTitle, + String(localized: .evidenceFormSaveErrorTitle), isPresented: $model.isShowingAttachmentError, presenting: model.attachmentError, ) { _ in - Button(Strings.commonOK, role: .cancel) {} + Button(.commonOk, role: .cancel) {} } message: { message in Text(message) } @@ -86,27 +86,27 @@ struct AddEvidenceView: View { model.removeAttachment() photoItem = nil } label: { - Label(Strings.evidenceRemoveAttachment, systemImage: "trash") + Label(.evidenceFormRemove, systemImage: "trash") } } else { Button { showingFileImporter = true } label: { - Label(Strings.evidenceChooseFile, systemImage: "doc") + Label(.evidenceFormChooseFile, systemImage: "doc") } PhotosPicker(selection: $photoItem, matching: .images) { - Label(Strings.evidenceChoosePhoto, systemImage: "photo") + Label(.evidenceFormChoosePhoto, systemImage: "photo") } } } header: { - Text(Strings.evidenceAttachmentHeader) + Text(.evidenceFormAttachmentHeader) } } private func attachmentSummary(_ attachment: PickedAttachment) -> some View { HStack { Label( - attachment.filename ?? Strings.evidenceAttachmentHeader, + attachment.filename ?? String(localized: .evidenceFormAttachmentHeader), systemImage: "paperclip", ) .lineLimit(1) @@ -122,15 +122,15 @@ struct AddEvidenceView: View { private var detailsSection: some View { @Bindable var model = model return Section { - Picker(Strings.evidenceKindPickerLabel, selection: $model.kind) { + Picker(String(localized: .evidenceFormKind), selection: $model.kind) { ForEach(EvidenceKind.knownCases, id: \.self) { kind in Label(kind.displayName, systemImage: kind.symbolName).tag(kind) } } if case .other = model.kind { - TextField(Strings.evidenceOtherLabelPlaceholder, text: $model.otherLabel) + TextField(String(localized: .evidenceFormOtherLabel), text: $model.otherLabel) } - DatePicker(Strings.evidenceDateLabel, selection: $model.capturedAt) + DatePicker(String(localized: .evidenceFormDate), selection: $model.capturedAt) } } @@ -138,13 +138,13 @@ struct AddEvidenceView: View { @Bindable var model = model return Section { TextField( - Strings.evidenceNotePlaceholder, + String(localized: .evidenceFormNotePlaceholder), text: $model.note, axis: .vertical, ) .lineLimit(3, reservesSpace: true) } header: { - Text(Strings.evidenceNoteLabel) + Text(.evidenceFormNote) } } @@ -189,7 +189,7 @@ struct AddEvidenceView: View { Task { do { guard let data = try await item.loadTransferable(type: Data.self) else { - model.reportAttachmentError(Strings.evidencePreviewFailed) + model.reportAttachmentError(String(localized: .evidenceDetailPreviewFailed)) return } model.setAttachment(PickedAttachment( diff --git a/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift b/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift index 743106d1..b063fbb1 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceBlobPreview.swift @@ -39,7 +39,7 @@ struct EvidenceBlobPreview: View { .scaledToFit() .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: stylesheet.evidence.previewCornerRadius)) - .accessibilityLabel(Strings.primaryEvidence) + .accessibilityLabel(String(localized: .primaryEvidence)) } else { failedToDecode } @@ -62,17 +62,17 @@ struct EvidenceBlobPreview: View { private var unavailable: some View { ContentUnavailableView { - Label(Strings.evidenceNoPreviewTitle, systemImage: "doc.questionmark") + Label(.evidenceDetailNoPreviewTitle, systemImage: "doc.questionmark") } description: { - Text(Strings.evidenceNoPreviewDescription) + Text(.evidenceDetailNoPreviewDescription) } } private var failedToDecode: some View { ContentUnavailableView { - Label(Strings.evidenceNoPreviewTitle, systemImage: "doc.questionmark") + Label(.evidenceDetailNoPreviewTitle, systemImage: "doc.questionmark") } description: { - Text(Strings.evidencePreviewFailed) + Text(.evidenceDetailPreviewFailed) } } } diff --git a/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift b/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift index 99e47789..89bad107 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceDetailView.swift @@ -51,7 +51,7 @@ struct EvidenceDetailView: View { } if let note = evidence.note, !note.isEmpty { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { - Text(Strings.evidenceDetailNoteHeader) + Text(.evidenceDetailNoteHeader) .font(.subheadline.weight(.semibold)) Text(note) } @@ -75,7 +75,7 @@ struct EvidenceDetailView: View { } case let .failed(message): ContentUnavailableView { - Label(Strings.evidenceFailedTitle, systemImage: "exclamationmark.icloud") + Label(.evidenceFailedTitle, systemImage: "exclamationmark.icloud") } description: { Text(message) } @@ -84,9 +84,9 @@ struct EvidenceDetailView: View { private var noAttachment: some View { ContentUnavailableView { - Label(Strings.evidenceNoAttachment, systemImage: "doc") + Label(.evidenceDetailNoAttachment, systemImage: "doc") } description: { - Text(Strings.evidenceNoPreviewDescription) + Text(.evidenceDetailNoPreviewDescription) } } } diff --git a/Where/WhereUI/Sources/Evidence/EvidenceKind+Presentation.swift b/Where/WhereUI/Sources/Evidence/EvidenceKind+Presentation.swift index bfc16816..270e4eea 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceKind+Presentation.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceKind+Presentation.swift @@ -24,6 +24,21 @@ extension EvidenceKind { /// Localized, user-facing name (the `.other` label when one was supplied). public var displayName: String { - Strings.evidenceKind(self) + switch self { + case .planeTicket: String(localized: .evidenceKindPlaneTicket) + case .boardingPass: String(localized: .evidenceKindBoardingPass) + case .hotelReceipt: String(localized: .evidenceKindHotelReceipt) + case .carRental: String(localized: .evidenceKindCarRental) + case .rideshare: String(localized: .evidenceKindRideshare) + case .photo: String(localized: .evidenceKindPhoto) + case .document: String(localized: .evidenceKindDocument) + case .email: String(localized: .evidenceKindEmail) + case let .other(label): + if let label, !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + label + } else { + String(localized: .evidenceKindOther) + } + } } } diff --git a/Where/WhereUI/Sources/Evidence/EvidenceListView.swift b/Where/WhereUI/Sources/Evidence/EvidenceListView.swift index 75d94f02..b0fcd81f 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceListView.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceListView.swift @@ -38,17 +38,18 @@ struct EvidenceListView: View { var body: some View { NavigationStack { content - .navigationTitle(Strings.evidenceListTitle(year: report.selectedYear)) + .navigationTitle(String(localized: .evidenceListTitle(WhereFormat + .year(report.selectedYear)))) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.commonDone) { dismiss() } + Button(.commonDone) { dismiss() } } ToolbarItem(placement: .topBarLeading) { Button { showingAdd = true } label: { - Label(Strings.evidenceAdd, systemImage: "plus") + Label(.evidenceAdd, systemImage: "plus") } .accessibilityIdentifier("where_add_evidence_button") } @@ -77,14 +78,14 @@ struct EvidenceListView: View { private var content: some View { switch model.loadState { case .idle, .loading: - AppIconLoadingView(caption: Strings.primaryLoading) + AppIconLoadingView(caption: String(localized: .primaryLoading)) case let .loaded(items): list(items) case .empty: emptyState case let .failed(message): ContentUnavailableView { - Label(Strings.evidenceFailedTitle, systemImage: "exclamationmark.icloud") + Label(.evidenceFailedTitle, systemImage: "exclamationmark.icloud") } description: { Text(message) } @@ -106,11 +107,11 @@ struct EvidenceListView: View { private var emptyState: some View { ContentUnavailableView { - Label(Strings.evidenceEmptyTitle, systemImage: "paperclip") + Label(.evidenceEmptyTitle, systemImage: "paperclip") } description: { - Text(Strings.evidenceEmptyDescription) + Text(.evidenceEmptyDescription) } actions: { - Button(Strings.evidenceAdd) { showingAdd = true } + Button(.evidenceAdd) { showingAdd = true } } } } @@ -144,7 +145,7 @@ private struct EvidenceRow: View { .padding(.vertical, stylesheet.spacing.xxSmall) .accessibilityElement(children: .ignore) .accessibilityLabel( - Strings.evidenceRowAccessibility(kind: evidence.kind, date: evidence.capturedAt), + WhereFormat.evidenceRowAccessibility(kind: evidence.kind, date: evidence.capturedAt), ) } } diff --git a/Where/WhereUI/Sources/Launch/LaunchSplashView.swift b/Where/WhereUI/Sources/Launch/LaunchSplashView.swift index 2e6ccc05..3fed5a16 100644 --- a/Where/WhereUI/Sources/Launch/LaunchSplashView.swift +++ b/Where/WhereUI/Sources/Launch/LaunchSplashView.swift @@ -65,7 +65,8 @@ struct LaunchSplashView: View { .background(splash.background) .ignoresSafeArea() .accessibilityElement(children: .ignore) - .accessibilityLabel(showCaption ? Strings.migrationTitle : Strings.launchAccessibilityLabel) + .accessibilityLabel(showCaption ? String(localized: .migrationTitle) : + String(localized: .launchAccessibilityLabel)) .task { try? await Task.sleep(for: Self.captionDelay) guard !Task.isCancelled else { return } @@ -82,9 +83,9 @@ struct LaunchSplashView: View { /// light since the backdrop is always dark. private var caption: some View { VStack(spacing: stylesheet.spacing.small) { - Text(Strings.migrationTitle) + Text(.migrationTitle) .font(.headline) - Text(Strings.migrationSubtitle) + Text(.migrationSubtitle) .font(.subheadline) .foregroundStyle(splash.captionSecondary) } diff --git a/Where/WhereUI/Sources/MainTabs.swift b/Where/WhereUI/Sources/MainTabs.swift index 6e6b9721..51e76f79 100644 --- a/Where/WhereUI/Sources/MainTabs.swift +++ b/Where/WhereUI/Sources/MainTabs.swift @@ -40,7 +40,7 @@ struct MainTabs: View { var body: some View { TabView(selection: $selection) { - Tab(Strings.tabPrimary, systemImage: "star.fill", value: TabID.primary) { + Tab(String(localized: .tabPrimary), systemImage: "star.fill", value: TabID.primary) { PrimaryView(report: report) .reportingDeveloperTabBarInset() } @@ -55,7 +55,7 @@ struct MainTabs: View { || selection == .elsewhere { Tab( - Strings.tabElsewhere, + String(localized: .tabElsewhere), systemImage: "globe.americas.fill", value: TabID.elsewhere, ) { @@ -65,14 +65,22 @@ struct MainTabs: View { } if !report.hideEmptyTabs || report.dataIssueCount > 0 || selection == .resolution { - Tab(Strings.tabResolution, systemImage: "checklist", value: TabID.resolution) { + Tab( + String(localized: .tabResolution), + systemImage: "checklist", + value: TabID.resolution, + ) { ResolutionView(report: report) .reportingDeveloperTabBarInset() } .badge(report.dataIssueCount) } - Tab(Strings.tabSettings, systemImage: "gearshape.fill", value: TabID.settings) { + Tab( + String(localized: .tabSettings), + systemImage: "gearshape.fill", + value: TabID.settings, + ) { SettingsView(report: report) .reportingDeveloperTabBarInset() } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index e3645f44..bc1c5523 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -398,7 +398,7 @@ public final class WhereSession { return SwiftDataInspectorConfiguration( container: container, modelTypes: SwiftDataStore.inspectorModelTypes, - title: Strings.developerInspectorTitle, + title: String(localized: .developerInspectorTitle), ) } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 8e80c918..e625b176 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -83,7 +83,7 @@ public struct OnboardingView: View { Button { withAnimation { page += 1 } } label: { - Text(Strings.onboardingContinue) + Text(.onboardingContinue) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) @@ -101,13 +101,13 @@ public struct OnboardingView: View { completeAndContinue() } } label: { - Text(Strings.onboardingEnableLocation) + Text(.onboardingEnableLocation) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) - Button(Strings.onboardingNotNow) { + Button(.onboardingNotNow) { guard !isFinishing else { return } isFinishing = true completeAndContinue() @@ -139,20 +139,20 @@ struct OnboardingPage: Identifiable { OnboardingPage( id: "welcome", symbol: "globe.americas.fill", - title: Strings.onboardingWelcomeTitle, - description: Strings.onboardingWelcomeDescription, + title: String(localized: .onboardingWelcomeTitle), + description: String(localized: .onboardingWelcomeDescription), ), OnboardingPage( id: "automatic", symbol: "location.fill.viewfinder", - title: Strings.onboardingAutomaticTitle, - description: Strings.onboardingAutomaticDescription, + title: String(localized: .onboardingAutomaticTitle), + description: String(localized: .onboardingAutomaticDescription), ), OnboardingPage( id: "privacy", symbol: "lock.shield.fill", - title: Strings.onboardingPrivacyTitle, - description: Strings.onboardingPrivacyDescription, + title: String(localized: .onboardingPrivacyTitle), + description: String(localized: .onboardingPrivacyDescription), ), ] } diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index e8daf9b9..3292daa8 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -50,7 +50,7 @@ struct CalendarView: View { case let .failure(error): calendarLayoutError(error) case nil: - AppIconLoadingView(caption: Strings.primaryLoading) + AppIconLoadingView(caption: String(localized: .primaryLoading)) } } .task(id: calendarLoadID(report: yearReport)) { @@ -59,18 +59,18 @@ struct CalendarView: View { monthsLoad = result } } else if report.loadState == .loading { - AppIconLoadingView(caption: Strings.primaryLoading) + AppIconLoadingView(caption: String(localized: .primaryLoading)) } else if case let .failed(error) = report.loadState { ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label(.commonLoadErrorTitle, systemImage: "exclamationmark.icloud") } description: { Text(error.message) } } else { ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label(.commonLoadErrorTitle, systemImage: "exclamationmark.icloud") } description: { - Text(Strings.calendarUnavailableDescription) + Text(.calendarUnavailableDescription) } .onAppear { Self.logger.warning( @@ -83,12 +83,13 @@ struct CalendarView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.commonDone) { dismiss() } + Button(.commonDone) { dismiss() } } } .navigationDestination(item: $timelineTarget) { target in PresenceTimelineList(report: report, scrollToMonth: target.startOfMonth) - .navigationTitle(Strings.timelineTitle(year: report.selectedYear)) + .navigationTitle(String(localized: .timelineTitle(WhereFormat + .year(report.selectedYear)))) .navigationBarTitleDisplayMode(.inline) } } @@ -96,9 +97,12 @@ struct CalendarView: View { private var navigationTitle: String { if let focusedRegion { - Strings.calendarRegionTitle(region: focusedRegion, year: report.selectedYear) + String(localized: .calendarRegionTitle( + focusedRegion.localizedName, + WhereFormat.year(report.selectedYear), + )) } else { - Strings.calendarTitle(year: report.selectedYear) + String(localized: .calendarTitle(WhereFormat.year(report.selectedYear))) } } @@ -126,9 +130,9 @@ struct CalendarView: View { private func calendarLayoutError(_ error: Error) -> some View { ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label(.commonLoadErrorTitle, systemImage: "exclamationmark.icloud") } description: { - Text(Strings.calendarUnavailableDescription) + Text(.calendarUnavailableDescription) } .onAppear { Self.logger.warning("Calendar layout failed: \(error)") @@ -261,7 +265,7 @@ private struct MonthFooter: View { .font(.subheadline) .fontWeight(isFocused ? .semibold : .regular) Spacer(minLength: 0) - Text(Strings.dayCount(tally.days)) + Text(WhereFormat.dayCount(tally.days)) .font(.subheadline) .monospacedDigit() .foregroundStyle(.secondary) @@ -269,7 +273,7 @@ private struct MonthFooter: View { .opacity(focusedRegion == nil || isFocused ? 1 : calendar.month.unfocusedRowOpacity) .accessibilityElement(children: .ignore) .accessibilityLabel( - Strings.regionDaysAccessibility( + WhereFormat.regionDaysAccessibility( region: tally.region.localizedName, days: tally.days, ), @@ -336,7 +340,7 @@ private struct DayCell: View { .contentShape(Rectangle()) .accessibilityElement(children: .ignore) .accessibilityLabel( - Strings.calendarDayAccessibility( + WhereFormat.calendarDayAccessibility( date: day.date, regions: day.regions, needsAttention: day.needsAttention, diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index 000271dc..38c1557b 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -12,11 +12,12 @@ struct PresenceTimelineView: View { var body: some View { NavigationStack { PresenceTimelineList(report: report) - .navigationTitle(Strings.timelineTitle(year: report.selectedYear)) + .navigationTitle(String(localized: .timelineTitle(WhereFormat + .year(report.selectedYear)))) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.timelineDone) { dismiss() } + Button(.timelineDone) { dismiss() } } } } @@ -39,9 +40,9 @@ struct PresenceTimelineList: View { var body: some View { if stints.isEmpty { ContentUnavailableView { - Label(Strings.timelineEmptyTitle, systemImage: "calendar.day.timeline.left") + Label(.timelineEmptyTitle, systemImage: "calendar.day.timeline.left") } description: { - Text(Strings.timelineEmptyDescription) + Text(.timelineEmptyDescription) } } else { ScrollViewReader { proxy in @@ -106,7 +107,7 @@ private struct StintRow: View { Spacer(minLength: timeline.trailingMinSpacing) - Text(Strings.dayCount(stint.dayCount)) + Text(WhereFormat.dayCount(stint.dayCount)) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .monospacedDigit() @@ -114,7 +115,7 @@ private struct StintRow: View { .padding(.vertical, timeline.rowVerticalPadding) .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.timelineRowAccessibility( + WhereFormat.timelineRowAccessibility( region: stint.region.localizedName, range: dateRange, days: stint.dayCount, diff --git a/Where/WhereUI/Sources/Primary/PrimaryView.swift b/Where/WhereUI/Sources/Primary/PrimaryView.swift index 8ba1976d..d5bfbb4c 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -44,7 +44,7 @@ struct PrimaryView: View { showingRecentActivity = true } label: { Label( - Strings.primaryRecentActivity, + .primaryRecentActivity, systemImage: "sparkles", ) } @@ -55,7 +55,7 @@ struct PrimaryView: View { showingTimeline = true } label: { Label( - Strings.primaryTimeline, + .primaryTimeline, systemImage: "calendar.day.timeline.left", ) } @@ -66,7 +66,7 @@ struct PrimaryView: View { showingCalendar = true } label: { Label( - Strings.primaryCalendar, + .primaryCalendar, systemImage: "calendar", ) } @@ -77,7 +77,7 @@ struct PrimaryView: View { showingEvidence = true } label: { Label( - Strings.primaryEvidence, + .primaryEvidence, systemImage: "paperclip", ) } @@ -126,10 +126,10 @@ struct PrimaryView: View { private var screen: some View { switch report.loadState { case .loading where report.report == nil: - AppIconLoadingView(caption: Strings.primaryLoading) + AppIconLoadingView(caption: String(localized: .primaryLoading)) case let .failed(error): ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label(.commonLoadErrorTitle, systemImage: "exclamationmark.icloud") } description: { Text(error.message) } @@ -168,7 +168,7 @@ struct PrimaryView: View { // Plain so the card's interactive Liquid Glass owns the // press feel rather than the button adding its own. .buttonStyle(.plain) - .accessibilityHint(Strings.primaryCardCalendarHint) + .accessibilityHint(String(localized: .primaryCardCalendarHint)) } } } @@ -179,17 +179,17 @@ struct PrimaryView: View { private var emptyState: some View { ContentUnavailableView { - Label(Strings.primaryEmptyTitle(year: report.selectedYear), systemImage: "map") + Label(.primaryEmptyTitle(WhereFormat.year(report.selectedYear)), systemImage: "map") } description: { - Text(Strings.primaryEmptyDescription) + Text(.primaryEmptyDescription) } } private var elsewhereOnlyState: some View { ContentUnavailableView { - Label(Strings.primaryElsewhereOnlyTitle, systemImage: "globe.americas") + Label(.primaryElsewhereOnlyTitle, systemImage: "globe.americas") } description: { - Text(Strings.primaryElsewhereOnlyDescription(count: report.trackedDayCount)) + Text(.primaryElsewhereOnlyDescription(report.trackedDayCount)) } } } diff --git a/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift index bc2f7d7f..9d1793fa 100644 --- a/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift +++ b/Where/WhereUI/Sources/Primary/RecentActivitySummaryView.swift @@ -29,17 +29,17 @@ struct RecentActivitySummaryView: View { content .safeAreaInset(edge: .top) { windowPicker } .animation(.smooth, value: model.loadState) - .navigationTitle(Strings.recentActivityTitle(model.window)) + .navigationTitle(WhereFormat.recentActivityTitle(model.window)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.commonDone) { dismiss() } + Button(.commonDone) { dismiss() } } ToolbarItem(placement: .topBarLeading) { Button { Task { await model.load() } } label: { - Label(Strings.recentActivityRefresh, systemImage: "arrow.clockwise") + Label(.recentActivityRefresh, systemImage: "arrow.clockwise") } .disabled(model.loadState == .loading) } @@ -61,9 +61,9 @@ struct RecentActivitySummaryView: View { /// turns a change into a reload. Disabled while a summary is generating so /// selections can't race an in-flight load. private var windowPicker: some View { - Picker(Strings.recentActivityWindowPickerLabel, selection: $model.window) { + Picker(String(localized: .recentActivityWindowPickerLabel), selection: $model.window) { ForEach(RecentActivityWindow.allCases, id: \.self) { window in - Text(Strings.recentActivityWindowLabel(window)).tag(window) + Text(WhereFormat.recentActivityWindowLabel(window)).tag(window) } } .pickerStyle(.segmented) @@ -80,29 +80,29 @@ struct RecentActivitySummaryView: View { private var content: some View { switch model.loadState { case .idle, .loading: - AppIconLoadingView(caption: Strings.recentActivityLoading) + AppIconLoadingView(caption: String(localized: .recentActivityLoading)) .transition(.opacity) case let .loaded(text): summary(text) .transition(.opacity) case .empty: ContentUnavailableView { - Label(Strings.recentActivityEmptyTitle, systemImage: "location.slash") + Label(.recentActivityEmptyTitle, systemImage: "location.slash") } description: { - Text(Strings.recentActivityEmptyDescription(model.window)) + Text(WhereFormat.recentActivityEmptyDescription(model.window)) } .transition(.opacity) case let .unavailable(reason): ContentUnavailableView { - Label(Strings.recentActivityUnavailableTitle, systemImage: "sparkles.slash") + Label(.recentActivityUnavailableTitle, systemImage: "sparkles.slash") } description: { - Text(Strings.recentActivityUnavailableMessage(reason)) + Text(WhereFormat.recentActivityUnavailableMessage(reason)) } .transition(.opacity) case let .failed(message): ContentUnavailableView { Label( - Strings.recentActivityFailedTitle, + .recentActivityFailedTitle, systemImage: "exclamationmark.triangle", ) } description: { @@ -118,7 +118,7 @@ struct RecentActivitySummaryView: View { TypewriterText(text: text) .font(.body) .frame(maxWidth: .infinity, alignment: .leading) - Text(Strings.recentActivityFooter(model.window)) + Text(WhereFormat.recentActivityFooter(model.window)) .font(.footnote) .foregroundStyle(.secondary) } diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index d495c532..ea6012f3 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -210,7 +210,7 @@ struct RegionSummaryCard: View { .font(card.heroNumberFont) .contentTransition(.numericText()) .foregroundStyle(style.tint) - Text(Strings.dayUnit(regionDays.days)) + Text(WhereFormat.dayUnit(regionDays.days)) .font(card.dayUnitFont) .foregroundStyle(.secondary) } @@ -260,7 +260,7 @@ struct RegionSummaryCard: View { ) .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.regionDaysAccessibility( + WhereFormat.regionDaysAccessibility( region: regionDays.region.localizedName, days: regionDays.days, ), diff --git a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift index 8fc660b0..2550fb50 100644 --- a/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift +++ b/Where/WhereUI/Sources/Resolution/AbruptChangeDetailView.swift @@ -16,10 +16,10 @@ struct AbruptChangeDetailView: View { if let payload = travelDayPayload { Form { Section { - Text(Strings.resolutionAbruptDetailExplanation) + Text(.resolutionAbruptDetailExplanation) } - Section(Strings.resolutionAbruptDetailEarlierHeader) { + Section(String(localized: .resolutionAbruptDetailEarlier)) { daySummary(payload.earlier) NavigationLink { DayRelabelView( @@ -28,11 +28,11 @@ struct AbruptChangeDetailView: View { initialRegions: payload.suggested, ) } label: { - Text(Strings.resolutionAbruptDetailRelabelEarlier) + Text(.resolutionAbruptDetailRelabelEarlier) } } - Section(Strings.resolutionAbruptDetailLaterHeader) { + Section(String(localized: .resolutionAbruptDetailLater)) { daySummary(payload.later) NavigationLink { DayRelabelView( @@ -41,12 +41,12 @@ struct AbruptChangeDetailView: View { initialRegions: payload.suggested, ) } label: { - Text(Strings.resolutionAbruptDetailRelabelLater) + Text(.resolutionAbruptDetailRelabelLater) } } Section { - Button(Strings.resolutionAbruptDetailBothRight) { + Button(.resolutionAbruptDetailBothRight) { Task { await resolve.dismiss(issue) dismiss() @@ -54,11 +54,11 @@ struct AbruptChangeDetailView: View { } } } - .navigationTitle(Strings.resolutionAbruptDetailTitle) + .navigationTitle(String(localized: .resolutionAbruptDetailTitle)) .navigationBarTitleDisplayMode(.inline) } else { ContentUnavailableView( - Strings.loadErrorTitle, + String(localized: .commonLoadErrorTitle), systemImage: "exclamationmark.triangle", ) } diff --git a/Where/WhereUI/Sources/Resolution/ResolutionView.swift b/Where/WhereUI/Sources/Resolution/ResolutionView.swift index 4d1284cb..bfa77f87 100644 --- a/Where/WhereUI/Sources/Resolution/ResolutionView.swift +++ b/Where/WhereUI/Sources/Resolution/ResolutionView.swift @@ -32,7 +32,7 @@ struct ResolutionView: View { var body: some View { NavigationStack { screen - .navigationTitle(Strings.resolutionTitle) + .navigationTitle(String(localized: .resolutionTitle)) .navigationBarTitleDisplayMode(.inline) .task(id: report.dataIssueScanInputs) { await resolve.load( @@ -47,10 +47,10 @@ struct ResolutionView: View { private var screen: some View { switch report.loadState { case .loading where report.report == nil: - AppIconLoadingView(caption: Strings.primaryLoading) + AppIconLoadingView(caption: String(localized: .primaryLoading)) case let .failed(error): ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label(.commonLoadErrorTitle, systemImage: "exclamationmark.icloud") } description: { Text(error.message) } @@ -59,12 +59,12 @@ struct ResolutionView: View { // The report is loaded but this tab's own scan hasn't landed // yet; show the loading state rather than flash "all clear" // under a non-zero badge. - AppIconLoadingView(caption: Strings.primaryLoading) + AppIconLoadingView(caption: String(localized: .primaryLoading)) } else if resolve.dataIssues.isEmpty { ContentUnavailableView { - Label(Strings.resolutionEmptyTitle, systemImage: "checkmark.seal") + Label(.resolutionEmptyTitle, systemImage: "checkmark.seal") } description: { - Text(Strings.resolutionEmptyDescription) + Text(.resolutionEmptyDescription) } } else { issueList @@ -83,7 +83,7 @@ struct ResolutionView: View { } } header: { Label( - Strings.resolutionSectionHeader(category), + WhereFormat.resolutionSectionHeader(category), systemImage: sectionIcon(category), ) } @@ -133,7 +133,7 @@ private struct IssueRow: View { Button(role: .destructive) { Task { await resolve.dismiss(issue) } } label: { - Label(Strings.resolutionDismiss, systemImage: "xmark") + Label(.resolutionDismiss, systemImage: "xmark") } } } @@ -158,7 +158,7 @@ private struct IssueRow: View { case let .relabelDay(day, _, _): day.date.formatted(.dateTime.month(.abbreviated).day().year()) case let .markTravelDay(earlier, later, _): - Strings.resolutionAbruptRowTitle( + WhereFormat.resolutionAbruptRowTitle( earlier: earlier.regions, later: later.regions, ) @@ -168,7 +168,7 @@ private struct IssueRow: View { private var subtitle: String? { switch issue.resolution { case let .backfill(range): - Strings.dayCount(range.dayCount) + WhereFormat.dayCount(range.dayCount) case let .relabelDay(_, suggested, meters): Self.relabelSubtitle(suggested: suggested, meters: meters) case let .markTravelDay(_, later, _): @@ -181,7 +181,7 @@ private struct IssueRow: View { let regionName = suggested.first?.localizedName ?? "" let distance = Measurement(value: meters, unit: UnitLength.meters) .formatted(.measurement(width: .abbreviated, usage: .road)) - return Strings.driftRowSubtitle(region: regionName, distance: distance) + return WhereFormat.driftRowSubtitle(region: regionName, distance: distance) } return suggested.map(\.localizedName).sorted().joined(separator: ", ") } diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index f0ba1714..3e409956 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -142,7 +142,7 @@ }, "calendar.day.accessibility" : { "comment" : "Accessibility label for a calendar day, including the day of the week and the names of the regions that day.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -155,7 +155,7 @@ }, "calendar.day.empty.accessibility" : { "comment" : "Accessibility label for a calendar day when no regions have been logged.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -166,9 +166,20 @@ } } }, + "calendar.day.hasEvidence.accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, has evidence" + } + } + } + }, "calendar.day.needsAttention.accessibility" : { "comment" : "Accessibility label for a calendar day that needs attention.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -181,7 +192,7 @@ }, "calendar.region.title" : { "comment" : "Title for the calendar when it's focused on a single region, e.g. \"California · 2026\".", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -194,7 +205,7 @@ }, "calendar.title" : { "comment" : "Title of the calendar screen, including the year.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -205,111 +216,595 @@ } } }, - "calendar.unavailable.description" : { - "comment" : "Description of the calendar when the user's year data isn't available.", - "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, + "calendar.unavailable.description" : { + "comment" : "Description of the calendar when the user's year data isn't available.", + "extractionState" : "manual", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your year data isn't available right now." + } + } + } + }, + "common.cancel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "common.day" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "day" + } + } + } + }, + "common.dayCount" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld day" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld days" + } + } + } + } + } + } + }, + "common.days" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "days" + } + } + } + }, + "common.done" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "common.loadError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't load your year" + } + } + } + }, + "common.ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "common.regionDays.accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, + "developer.button.label" : { + "comment" : "Accessibility label for the floating developer button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer tools" + } + } + } + }, + "developer.close" : { + "comment" : "Accessibility label for closing the developer panel.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + } + } + }, + "developer.collapse" : { + "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit full screen" + } + } + } + }, + "developer.expand" : { + "comment" : "Accessibility label for growing the developer panel to full screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter full screen" + } + } + } + }, + "developer.footer" : { + "comment" : "Footer for the developer tools list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device logs and data tools. Debug builds only." + } + } + } + }, + "developer.inspectorLink" : { + "comment" : "Label for the navigation link that opens the SwiftData inspector.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftData Inspector" + } + } + } + }, + "developer.inspectorTitle" : { + "comment" : "Navigation title for the SwiftData inspector screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftData" + } + } + } + }, + "developer.logsLink" : { + "comment" : "Label for the button that opens the logs screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, + "developer.logsTitle" : { + "comment" : "Title of the screen that shows the user's logs.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, + "developer.regionMapLink" : { + "comment" : "Label for the navigation link that opens the region map.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region map" + } + } + } + }, + "developer.title" : { + "comment" : "Navigation title of the developer tools list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer" + } + } + } + }, + "evidence.add" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add evidence" + } + } + } + }, + "evidence.detail.noAttachment" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No attachment" + } + } + } + }, + "evidence.detail.noPreview.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This attachment can't be previewed here." + } + } + } + }, + "evidence.detail.noPreview.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No preview" + } + } + } + }, + "evidence.detail.noteHeader" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note" + } + } + } + }, + "evidence.detail.previewFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't load this attachment." + } + } + } + }, + "evidence.empty.description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attach a boarding pass, receipt, or screenshot to back up where you were. Add one here, or share it into Where from another app." + } + } + } + }, + "evidence.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No evidence yet" + } + } + } + }, + "evidence.failed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't load evidence" + } + } + } + }, + "evidence.form.attachmentHeader" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attachment" + } + } + } + }, + "evidence.form.chooseFile" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose file" + } + } + } + }, + "evidence.form.choosePhoto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose photo" + } + } + } + }, + "evidence.form.date" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" + } + } + } + }, + "evidence.form.kind" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kind" + } + } + } + }, + "evidence.form.note" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note" + } + } + } + }, + "evidence.form.notePlaceholder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add a note (optional)" + } + } + } + }, + "evidence.form.otherLabel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + } + } + }, + "evidence.form.remove" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove attachment" + } + } + } + }, + "evidence.form.save" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "evidence.form.saveError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't save evidence" + } + } + } + }, + "evidence.kind.boardingPass" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boarding pass" + } + } + } + }, + "evidence.kind.carRental" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Car rental" + } + } + } + }, + "evidence.kind.document" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Document" + } + } + } + }, + "evidence.kind.email" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { - "state" : "new", - "value" : "Your year data isn't available right now." + "state" : "translated", + "value" : "Email" } } } }, - "common.day" : { + "evidence.kind.hotelReceipt" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "day" + "value" : "Hotel receipt" } } } }, - "common.dayCount" : { + "evidence.kind.other" : { "extractionState" : "manual", "localizations" : { "en" : { - "variations" : { - "plural" : { - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld day" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld days" - } - } - } + "stringUnit" : { + "state" : "translated", + "value" : "Other" } } } }, - "common.days" : { + "evidence.kind.photo" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "days" + "value" : "Photo" } } } }, - "common.done" : { + "evidence.kind.planeTicket" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Done" + "value" : "Plane ticket" } } } }, - "common.loadError.title" : { + "evidence.kind.rideshare" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Couldn't load your year" + "value" : "Rideshare" } } } }, - "common.ok" : { + "evidence.list.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Evidence · %@" } } } }, - "common.regionDays.accessibility" : { + "evidence.row.accessibility" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@: %2$@" + "value" : "%@, %@" } } } }, "launch.accessibilityLabel" : { "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -521,7 +1016,7 @@ }, "migration.subtitle" : { "comment" : "Subtitle for the migration screen.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -534,7 +1029,7 @@ }, "migration.title" : { "comment" : "Title of the migration screen.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -646,7 +1141,7 @@ }, "onboarding.automatic.description" : { "comment" : "Description of the automatic location logging feature in the onboarding flow.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -659,7 +1154,7 @@ }, "onboarding.automatic.title" : { "comment" : "Title of the first onboarding screen, describing how Where automatically logs your location.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -672,7 +1167,7 @@ }, "onboarding.continue" : { "comment" : "Button text for continuing to the next onboarding step.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -685,7 +1180,7 @@ }, "onboarding.enableLocation" : { "comment" : "Button text to prompt the user to enable location services.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -698,7 +1193,7 @@ }, "onboarding.notNow" : { "comment" : "Button title for skipping the onboarding flow.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -711,7 +1206,7 @@ }, "onboarding.privacy.description" : { "comment" : "Description of the privacy policy of the app.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -724,7 +1219,7 @@ }, "onboarding.privacy.title" : { "comment" : "Title of the privacy section of the onboarding flow.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -737,7 +1232,7 @@ }, "onboarding.welcome.description" : { "comment" : "A longer description of Where, for the onboarding welcome screen.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -750,7 +1245,7 @@ }, "onboarding.welcome.title" : { "comment" : "Title of the onboarding welcome screen.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -763,7 +1258,7 @@ }, "primary.calendar" : { "comment" : "Label for the primary calendar.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -776,7 +1271,7 @@ }, "primary.card.calendarHint" : { "comment" : "Accessibility hint on a Primary region card: tapping it opens that region's calendar, filtered to the days spent there.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -843,6 +1338,17 @@ } } }, + "primary.evidence" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evidence" + } + } + } + }, "primary.loading" : { "extractionState" : "manual", "localizations" : { @@ -1351,7 +1857,7 @@ }, "resolution.abrupt.detail.bothRight" : { "comment" : "Label for a button that marks both days as travel days.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1364,7 +1870,7 @@ }, "resolution.abrupt.detail.earlier" : { "comment" : "Label for the earlier day in the \"Sudden location change\" detail view.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1377,7 +1883,7 @@ }, "resolution.abrupt.detail.explanation" : { "comment" : "Explanation of the abrupt location change detail.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1390,7 +1896,7 @@ }, "resolution.abrupt.detail.later" : { "comment" : "Label for the column of a row in the \"Sudden location change\" detail view.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1403,7 +1909,7 @@ }, "resolution.abrupt.detail.relabelEarlier" : { "comment" : "Label for a button that lets the user mark a day as a travel day when it was previously marked as a non-travel day.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1416,7 +1922,7 @@ }, "resolution.abrupt.detail.relabelLater" : { "comment" : "Label for a button that relabels a day as a travel day.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1429,7 +1935,7 @@ }, "resolution.abrupt.detail.title" : { "comment" : "Title of a screen that shows a list of abrupt location changes.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1442,7 +1948,7 @@ }, "resolution.abrupt.rowTitle" : { "comment" : "Title of a row in the resolution screen that shows the regions that the user has been in and the regions that they were in before.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1455,7 +1961,7 @@ }, "resolution.dismiss" : { "comment" : "Label for a button that dismisses a resolution.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1468,7 +1974,7 @@ }, "resolution.drift.subtitle" : { "comment" : "Subtitle for a row in the resolution screen that describes a location that is near the border.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1481,7 +1987,7 @@ }, "resolution.empty.description" : { "comment" : "Description of an empty state when there are no resolution issues.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1494,7 +2000,7 @@ }, "resolution.empty.title" : { "comment" : "Title of the resolution screen when all missing days have been resolved.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1507,7 +2013,7 @@ }, "resolution.section.abruptChange" : { "comment" : "Label for a section of the resolution screen that lists days with abrupt location changes.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1520,7 +2026,7 @@ }, "resolution.section.borderDrift" : { "comment" : "Label for a section of the resolution screen that lists days that are near the border.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1533,7 +2039,7 @@ }, "resolution.section.missingDays" : { "comment" : "Label for a section of the resolution screen that lists missing days.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1546,7 +2052,7 @@ }, "resolution.title" : { "comment" : "Title of a screen that lets the user resolve a missing day.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -1766,57 +2272,57 @@ } } }, - "settings.backup.imported.message" : { + "settings.backup.importStrategy.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Imported %lld location samples, %lld pieces of evidence, %lld manual days, and %lld dismissed issues." + "value" : "Merge keeps everything already on this device and adds the file's records. Replace erases this device first, then restores only what's in the file." } } } }, - "settings.backup.imported.title" : { + "settings.backup.importStrategy.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Backup imported" + "value" : "Import backup" } } } }, - "settings.backup.importing" : { + "settings.backup.imported.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Importing…" + "value" : "Imported %lld location samples, %lld pieces of evidence, %lld manual days, and %lld dismissed issues." } } } }, - "settings.backup.importStrategy.message" : { + "settings.backup.imported.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Merge keeps everything already on this device and adds the file's records. Replace erases this device first, then restores only what's in the file." + "value" : "Backup imported" } } } }, - "settings.backup.importStrategy.title" : { + "settings.backup.importing" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Import backup" + "value" : "Importing…" } } } @@ -1909,134 +2415,46 @@ } } }, - "developer.button.label" : { - "comment" : "Accessibility label for the floating developer button.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Developer tools" - } - } - } - }, - "developer.close" : { - "comment" : "Accessibility label for closing the developer panel.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" - } - } - } - }, - "developer.collapse" : { - "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Exit full screen" - } - } - } - }, - "developer.expand" : { - "comment" : "Accessibility label for growing the developer panel to full screen.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter full screen" - } - } - } - }, - "developer.footer" : { - "comment" : "Footer for the developer tools list.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "On-device logs and data tools. Debug builds only." - } - } - } - }, - "developer.inspectorLink" : { - "comment" : "Label for the navigation link that opens the SwiftData inspector.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "SwiftData Inspector" - } - } - } - }, - "developer.inspectorTitle" : { - "comment" : "Navigation title for the SwiftData inspector screen.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "SwiftData" - } - } - } - }, - "developer.logsLink" : { - "comment" : "Label for the button that opens the logs screen.", + "settings.issueAlerts.deniedFooter" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." } } } }, - "developer.logsTitle" : { - "comment" : "Title of the screen that shows the user's logs.", + "settings.issueAlerts.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." } } } }, - "developer.regionMapLink" : { - "comment" : "Label for the navigation link that opens the region map.", + "settings.issueAlerts.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Region map" + "value" : "Issue alerts" } } } }, - "developer.title" : { - "comment" : "Navigation title of the developer tools list.", + "settings.issueAlerts.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Developer" + "value" : "Issue alerts" } } } @@ -2230,7 +2648,7 @@ }, "settings.reset.confirm" : { "comment" : "Button text for erasing all data and resetting the app.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2243,7 +2661,7 @@ }, "settings.reset.erase" : { "comment" : "Label for the button to erase all data and reset the app.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2256,7 +2674,7 @@ }, "settings.reset.footer" : { "comment" : "Footer for the reset confirmation dialog.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2269,7 +2687,7 @@ }, "settings.reset.message" : { "comment" : "Confirmation message for the reset action.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2282,7 +2700,7 @@ }, "settings.resolution.footer" : { "comment" : "Footer for the settings screen that explains the meaning of the resolution distance.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2295,7 +2713,7 @@ }, "settings.resolution.header" : { "comment" : "Title of the settings screen for data resolution.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2372,50 +2790,6 @@ } } }, - "settings.issueAlerts.deniedFooter" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." - } - } - } - }, - "settings.issueAlerts.footer" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." - } - } - } - }, - "settings.issueAlerts.header" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Issue alerts" - } - } - } - }, - "settings.issueAlerts.toggle" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Issue alerts" - } - } - } - }, "settings.summary.deniedFooter" : { "extractionState" : "manual", "localizations" : { @@ -2617,7 +2991,7 @@ }, "widget.today.empty" : { "comment" : "Text displayed in the widget when no data is available for the current day.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2630,7 +3004,7 @@ }, "widget.today.title" : { "comment" : "Title of the \"Today\" section in a widget.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2643,7 +3017,7 @@ }, "widget.year.empty" : { "comment" : "Text displayed in a widget when there are no days logged in a year.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2656,7 +3030,7 @@ }, "widget.year.title" : { "comment" : "Title of a year widget.", - "extractionState" : "extracted_with_value", + "extractionState" : "manual", "isCommentAutoGenerated" : true, "localizations" : { "en" : { @@ -2669,4 +3043,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index c2429dde..322cefd0 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -44,7 +44,7 @@ struct DayRelabelView: View { Form { Section { - LabeledContent(Strings.relabelTitle, value: dateText) + LabeledContent(String(localized: .relabelTitle), value: dateText) } Section { @@ -52,58 +52,58 @@ struct DayRelabelView: View { RegionToggleRow(item: item) } } header: { - Text(Strings.relabelRegionsHeader) + Text(.relabelRegionsHeader) } footer: { - Text(Strings.relabelRegionsFooter) + Text(.relabelRegionsFooter) } Section { TextField( - Strings.manualNotePlaceholder, + String(localized: .manualNotePlaceholder), text: $note, axis: .vertical, ) .lineLimit(3, reservesSpace: true) .disabled(pending != nil) } header: { - Text(Strings.manualNoteHeader) + Text(.manualNoteHeader) } footer: { - Text(Strings.manualNoteFooter) + Text(.manualNoteFooter) } if pending == .saving { Section { - SavingStatusRow(text: Strings.manualSavingStatus) + SavingStatusRow(text: String(localized: .manualSavingStatus)) } } auditSection Section { - Button(Strings.relabelReset, role: .destructive) { reset() } + Button(.relabelReset, role: .destructive) { reset() } .disabled(pending != nil) } footer: { - Text(Strings.relabelResetFooter) + Text(.relabelResetFooter) } } .animation(.default, value: pending) - .navigationTitle(Strings.relabelTitle) + .navigationTitle(String(localized: .relabelTitle)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { if pending == .saving { ProgressView() } else { - Button(Strings.manualSave) { save() } + Button(.manualSave) { save() } .disabled(!canSave) } } } .alert( - Strings.manualSaveErrorTitle, + String(localized: .manualSaveErrorTitle), isPresented: $saveError.isPresented, ) { - Button(Strings.commonOK, role: .cancel) {} + Button(.commonOk, role: .cancel) {} } message: { if let saveError = saveError.message { Text(saveError) @@ -118,13 +118,19 @@ struct DayRelabelView: View { private var auditSection: some View { if let audit = day.audit { Section { - LabeledContent(Strings.auditRecordedAt, value: recordedAtText(audit.recordedAt)) + LabeledContent( + String(localized: .auditRecordedAt), + value: recordedAtText(audit.recordedAt), + ) if let note = audit.note { - LabeledContent(Strings.auditNote, value: note) + LabeledContent(String(localized: .auditNote), value: note) } - LabeledContent(Strings.auditLocation, value: locationText(audit.location)) + LabeledContent( + String(localized: .auditLocation), + value: locationText(audit.location), + ) } header: { - Text(Strings.auditHeader) + Text(.auditHeader) } } } @@ -138,8 +144,8 @@ struct DayRelabelView: View { } private func locationText(_ location: CapturedLocation?) -> String { - guard let location else { return Strings.auditLocationUnavailable } - return Strings.auditCoordinate( + guard let location else { return String(localized: .auditLocationUnavailable) } + return WhereFormat.coordinate( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude, ) diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index 0fb0d062..b1fdb4aa 100644 --- a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift +++ b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift @@ -56,9 +56,9 @@ struct RegionDaysView: View { private var content: some View { if days.isEmpty { ContentUnavailableView { - Label(Strings.secondaryRegionEmptyTitle, systemImage: "checkmark.circle") + Label(.secondaryRegionEmptyTitle, systemImage: "checkmark.circle") } description: { - Text(Strings.secondaryRegionEmptyDescription) + Text(.secondaryRegionEmptyDescription) } } else { VStack(spacing: 0) { @@ -88,7 +88,7 @@ struct RegionDaysView: View { } .mapStyle(.standard(pointsOfInterest: .excludingAll)) .frame(height: regionMap.height) - .accessibilityLabel(Strings.secondaryRegionMapAccessibility) + .accessibilityLabel(String(localized: .secondaryRegionMapAccessibility)) } /// Radius in meters to draw for a pin's GPS uncertainty, or `nil` when the @@ -114,7 +114,7 @@ struct RegionDaysView: View { } } } footer: { - Text(Strings.secondaryRegionFooter) + Text(.secondaryRegionFooter) } } .accessibilityIdentifier("where_region_days_list") @@ -185,7 +185,7 @@ private struct DayRow: View { .font(.subheadline) .foregroundStyle(.primary) } - Text(Strings.secondaryRegionCurrent(regions: regionsText)) + Text(.secondaryRegionCurrent(regionsText)) .font(.footnote) .foregroundStyle(.secondary) } diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index e91edd19..1a09841b 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -17,7 +17,7 @@ struct SecondaryView: View { var body: some View { NavigationStack { screen - .navigationTitle(Strings.secondaryTitle) + .navigationTitle(String(localized: .secondaryTitle)) .toolbar { ToolbarItem(placement: .topBarTrailing) { YearSelector(report: report) @@ -48,11 +48,11 @@ struct SecondaryView: View { private var screen: some View { switch report.loadState { case .loading where report.report == nil: - ProgressView(Strings.secondaryLoading) + ProgressView(String(localized: .secondaryLoading)) .frame(maxWidth: .infinity, maxHeight: .infinity) case let .failed(error): ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label(.commonLoadErrorTitle, systemImage: "exclamationmark.icloud") } description: { Text(error.message) } @@ -68,7 +68,7 @@ struct SecondaryView: View { private var content: some View { ScrollView { VStack(alignment: .leading, spacing: stylesheet.spacing.xLarge) { - Text(Strings.secondaryHeader(year: report.selectedYear)) + Text(.secondaryHeader(WhereFormat.year(report.selectedYear))) .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -99,15 +99,15 @@ struct SecondaryView: View { private var emptyState: some View { ContentUnavailableView { - Label(Strings.secondaryEmptyTitle, systemImage: "globe.americas") + Label(.secondaryEmptyTitle, systemImage: "globe.americas") } description: { - Text(Strings.secondaryEmptyDescription) + Text(.secondaryEmptyDescription) } } /// Light whimsy for the briefest stays. private func caption(for item: RegionDays) -> String? { - item.days <= 3 ? Strings.secondaryCaptionPassingThrough : nil + item.days <= 3 ? String(localized: .secondaryCaptionPassingThrough) : nil } } diff --git a/Where/WhereUI/Sources/Settings/AppIconView.swift b/Where/WhereUI/Sources/Settings/AppIconView.swift index 134e3ebe..e3cf5df0 100644 --- a/Where/WhereUI/Sources/Settings/AppIconView.swift +++ b/Where/WhereUI/Sources/Settings/AppIconView.swift @@ -48,11 +48,11 @@ struct AppIconView: View { .transition(.move(edge: .bottom)) } } - .navigationTitle(Strings.appIconTitle) + .navigationTitle(String(localized: .appIconTitle)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.commonDone) { dismiss() } + Button(.commonDone) { dismiss() } } } } @@ -62,8 +62,8 @@ struct AppIconView: View { } .sensoryFeedback(.selection, trigger: appearanceToggles) .sensoryFeedback(.success, trigger: model.selectedID) - .alert(Strings.appIconErrorTitle, isPresented: $model.isShowingError) { - Button(Strings.commonOK, role: .cancel) {} + .alert(String(localized: .appIconErrorTitle), isPresented: $model.isShowingError) { + Button(.commonOk, role: .cancel) {} } message: { Text(model.applyError ?? "") } @@ -121,7 +121,7 @@ struct AppIconView: View { .buttonStyle(.plain) .accessibilityElement(children: .combine) .accessibilityLabel(option.displayName) - .accessibilityValue(isSelected ? Strings.appIconCurrent : "") + .accessibilityValue(isSelected ? String(localized: .appIconCurrent) : "") .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } @@ -132,7 +132,7 @@ struct AppIconView: View { .transition(.opacity) .onTapGesture { dismissPreview() } .accessibilityAddTraits(.isButton) - .accessibilityLabel(Strings.commonDone) + .accessibilityLabel(String(localized: .commonDone)) .accessibilityAction { dismissPreview() } } @@ -153,14 +153,14 @@ struct AppIconView: View { } .buttonStyle(.plain) .accessibilityLabel(option.displayName) - .accessibilityValue(previewMode == .dark ? Strings.appIconAppearanceDark : Strings - .appIconAppearanceLight) - .accessibilityHint(Strings.appIconAppearanceHint) + .accessibilityValue(previewMode == .dark ? String(localized: .appIconAppearanceDark) : + String(localized: .appIconAppearanceLight)) + .accessibilityHint(String(localized: .appIconAppearanceHint)) VStack(spacing: appIcon.panel.textSpacing) { Text(option.displayName) .font(.title3.weight(.semibold)) - Text(Strings.appIconAppearanceHint) + Text(.appIconAppearanceHint) .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -169,7 +169,7 @@ struct AppIconView: View { Button { apply(option) } label: { - Text(Strings.appIconSet) + Text(.appIconSet) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) diff --git a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift index de920319..a472aa47 100644 --- a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift +++ b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift @@ -39,7 +39,7 @@ struct LocationStatusRow: View { return Presentation( symbol: "location.fill", tint: .green, - title: Strings.settingsStatusTracking, + title: String(localized: .settingsStatusTracking), ) } switch status { @@ -47,31 +47,31 @@ struct LocationStatusRow: View { return Presentation( symbol: "location.fill", tint: .green, - title: Strings.settingsStatusAlwaysPaused, + title: String(localized: .settingsStatusAlwaysPaused), ) case .whenInUse: return Presentation( symbol: "location", tint: .orange, - title: Strings.settingsStatusWhenInUse, + title: String(localized: .settingsStatusWhenInUse), ) case .notDetermined: return Presentation( symbol: "location.slash", tint: .secondary, - title: Strings.settingsStatusNotDetermined, + title: String(localized: .settingsStatusNotDetermined), ) case .denied: return Presentation( symbol: "location.slash.fill", tint: .red, - title: Strings.settingsStatusDenied, + title: String(localized: .settingsStatusDenied), ) case .restricted: return Presentation( symbol: "lock.fill", tint: .red, - title: Strings.settingsStatusRestricted, + title: String(localized: .settingsStatusRestricted), ) } } diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index fb03937b..e8b9d5ef 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -20,8 +20,8 @@ struct ManualDayEntryView: View { var title: String { switch self { - case .singleDay: Strings.manualModeSingleDay - case .range: Strings.manualModeRange + case .singleDay: String(localized: .manualModeSingleDay) + case .range: String(localized: .manualModeRange) } } } @@ -65,7 +65,7 @@ struct ManualDayEntryView: View { Form { Section { - Picker(Strings.manualEntryPickerLabel, selection: $mode) { + Picker(String(localized: .manualEntryPickerLabel), selection: $mode) { ForEach(EntryMode.allCases) { mode in Text(mode.title).tag(mode) } @@ -83,33 +83,33 @@ struct ManualDayEntryView: View { RegionToggleRow(item: item) } } header: { - Text(Strings.manualRegionsHeader) + Text(.manualRegionsHeader) } footer: { - Text(Strings.manualRegionsFooter) + Text(.manualRegionsFooter) } Section { TextField( - Strings.manualNotePlaceholder, + String(localized: .manualNotePlaceholder), text: $note, axis: .vertical, ) .lineLimit(3, reservesSpace: true) .disabled(isSaving) } header: { - Text(Strings.manualNoteHeader) + Text(.manualNoteHeader) } footer: { - Text(Strings.manualNoteFooter) + Text(.manualNoteFooter) } if isSaving { Section { - SavingStatusRow(text: Strings.manualSavingStatus) + SavingStatusRow(text: String(localized: .manualSavingStatus)) } } } .animation(.default, value: isSaving) - .navigationTitle(Strings.manualTitle) + .navigationTitle(String(localized: .manualTitle)) .navigationBarTitleDisplayMode(.inline) .onChange(of: startDate) { _, newValue in if endDate < newValue { endDate = newValue } @@ -119,16 +119,16 @@ struct ManualDayEntryView: View { if isSaving { ProgressView() } else { - Button(Strings.manualSave) { save() } + Button(.manualSave) { save() } .disabled(!canSave) } } } .alert( - Strings.manualSaveErrorTitle, + String(localized: .manualSaveErrorTitle), isPresented: $saveError.isPresented, ) { - Button(Strings.commonOK, role: .cancel) {} + Button(.commonOk, role: .cancel) {} } message: { if let saveError = saveError.message { Text(saveError) @@ -141,20 +141,20 @@ struct ManualDayEntryView: View { switch mode { case .singleDay: DatePicker( - Strings.manualDay, + String(localized: .manualDay), selection: $startDate, in: ...Date(), displayedComponents: .date, ) case .range: DatePicker( - Strings.manualFrom, + String(localized: .manualFrom), selection: $startDate, in: ...Date(), displayedComponents: .date, ) DatePicker( - Strings.manualThrough, + String(localized: .manualThrough), selection: $endDate, in: startDate ... Date(), displayedComponents: .date, @@ -165,9 +165,9 @@ struct ManualDayEntryView: View { private var dateFooter: String { switch mode { case .singleDay: - Strings.manualSingleDayFooter + String(localized: .manualSingleDayFooter) case .range: - Strings.manualRangeFooter(count: dayCount) + String(localized: .manualRangeFooter(dayCount)) } } diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index d3b40895..de43698c 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -62,7 +62,7 @@ struct SettingsView: View { dataSection resetSection } - .navigationTitle(Strings.settingsTitle) + .navigationTitle(String(localized: .settingsTitle)) // Notification permission can change in the Settings app while we're // away; refresh it when the screen appears so the "open Settings" // affordance is accurate. @@ -70,11 +70,14 @@ struct SettingsView: View { .sheet(isPresented: $showAppIcon) { AppIconView() } - .alert(Strings.settingsPermissionAlertTitle, isPresented: $session.permissionDenied) { - Button(Strings.settingsPermissionAlertOpenSettings) { openSystemSettings() } - Button(Strings.settingsPermissionAlertNotNow, role: .cancel) {} + .alert( + String(localized: .settingsPermissionAlertTitle), + isPresented: $session.permissionDenied, + ) { + Button(.settingsPermissionAlertOpenSettings) { openSystemSettings() } + Button(.settingsPermissionAlertNotNow, role: .cancel) {} } message: { - Text(Strings.settingsPermissionAlertMessage) + Text(.settingsPermissionAlertMessage) } .fileImporter( isPresented: $showImporter, @@ -82,39 +85,39 @@ struct SettingsView: View { onCompletion: handleImportSelection, ) .confirmationDialog( - Strings.settingsBackupImportStrategyTitle, + String(localized: .settingsBackupImportStrategyTitle), isPresented: $showStrategyDialog, titleVisibility: .visible, presenting: pendingImportURL, ) { url in - Button(Strings.settingsBackupMerge) { runImport(url: url, strategy: .merge) } - Button(Strings.settingsBackupReplace, role: .destructive) { + Button(.settingsBackupMerge) { runImport(url: url, strategy: .merge) } + Button(.settingsBackupReplace, role: .destructive) { runImport(url: url, strategy: .replace) } - Button(Strings.settingsDataCancel, role: .cancel) { pendingImportURL = nil } + Button(.settingsDataCancel, role: .cancel) { pendingImportURL = nil } } message: { _ in - Text(Strings.settingsBackupImportStrategyMessage) + Text(.settingsBackupImportStrategyMessage) } .alert( - Strings.settingsBackupImportedTitle, + String(localized: .settingsBackupImportedTitle), isPresented: $showImportSuccess, presenting: lastImportSummary, ) { _ in - Button(Strings.commonOK, role: .cancel) {} + Button(.commonOk, role: .cancel) {} } message: { summary in - Text(Strings.settingsBackupImportedMessage( - samples: summary.sampleCount, - evidence: summary.evidenceCount, - manualDays: summary.manualDayCount, - dismissedIssues: summary.dismissedIssueCount, + Text(.settingsBackupImportedMessage( + summary.sampleCount, + summary.evidenceCount, + summary.manualDayCount, + summary.dismissedIssueCount, )) } .alert( - Strings.settingsBackupErrorTitle, + String(localized: .settingsBackupErrorTitle), isPresented: $backup.isShowingBackupError, presenting: backup.backupError, ) { _ in - Button(Strings.commonOK, role: .cancel) {} + Button(.commonOk, role: .cancel) {} } message: { message in Text(message) } @@ -127,14 +130,14 @@ struct SettingsView: View { LocationStatusRow(status: session.authorizationStatus, isTracking: session.isTracking) Toggle(isOn: $session.trackingEnabled) { - Label(Strings.settingsLocationToggle, systemImage: "location.fill") + Label(.settingsLocationToggle, systemImage: "location.fill") } if showGrantButton { Button { Task { await session.requestPermission() } } label: { - Label(Strings.settingsLocationGrant, systemImage: "location.magnifyingglass") + Label(.settingsLocationGrant, systemImage: "location.magnifyingglass") } } @@ -142,13 +145,13 @@ struct SettingsView: View { Button { openSystemSettings() } label: { - Label(Strings.settingsPermissionAlertOpenSettings, systemImage: "gear") + Label(.settingsPermissionAlertOpenSettings, systemImage: "gear") } } } header: { - Text(Strings.settingsLocationHeader) + Text(.settingsLocationHeader) } footer: { - Text(Strings.settingsLocationFooter) + Text(.settingsLocationFooter) } } @@ -173,12 +176,12 @@ struct SettingsView: View { @Bindable var reminders = reminders return Section { Toggle(isOn: $reminders.remindersEnabled) { - Label(Strings.settingsRemindersToggle, systemImage: "bell.badge") + Label(.settingsRemindersToggle, systemImage: "bell.badge") } if reminders.remindersEnabled { DatePicker( - Strings.settingsReminderTime, + String(localized: .settingsRemindersTime), selection: $reminders.reminderTimeOfDay, displayedComponents: .hourAndMinute, ) @@ -187,12 +190,12 @@ struct SettingsView: View { Button { openSystemSettings() } label: { - Label(Strings.settingsRemindersOpenSettings, systemImage: "bell.slash") + Label(.settingsRemindersOpenSettings, systemImage: "bell.slash") } } } } header: { - Text(Strings.settingsRemindersHeader) + Text(.settingsRemindersHeader) } footer: { Text(remindersFooter) } @@ -200,21 +203,21 @@ struct SettingsView: View { private var remindersFooter: String { if reminders.remindersEnabled, !reminders.notificationsAuthorized { - return Strings.settingsRemindersDeniedFooter + return String(localized: .settingsRemindersDeniedFooter) } - return Strings.settingsRemindersFooter + return String(localized: .settingsRemindersFooter) } private var summarySection: some View { @Bindable var reminders = reminders return Section { Toggle(isOn: $reminders.summaryEnabled) { - Label(Strings.settingsSummaryToggle, systemImage: "chart.bar.doc.horizontal") + Label(.settingsSummaryToggle, systemImage: "chart.bar.doc.horizontal") } if reminders.summaryEnabled { DatePicker( - Strings.settingsSummaryTime, + String(localized: .settingsSummaryTime), selection: $reminders.summaryTimeOfDay, displayedComponents: .hourAndMinute, ) @@ -223,12 +226,12 @@ struct SettingsView: View { Button { openSystemSettings() } label: { - Label(Strings.settingsRemindersOpenSettings, systemImage: "bell.slash") + Label(.settingsRemindersOpenSettings, systemImage: "bell.slash") } } } } header: { - Text(Strings.settingsSummaryHeader) + Text(.settingsSummaryHeader) } footer: { Text(summaryFooter) } @@ -236,27 +239,27 @@ struct SettingsView: View { private var summaryFooter: String { if reminders.summaryEnabled, !reminders.notificationsAuthorized { - return Strings.settingsSummaryDeniedFooter + return String(localized: .settingsSummaryDeniedFooter) } - return Strings.settingsSummaryFooter + return String(localized: .settingsSummaryFooter) } private var issueAlertsSection: some View { @Bindable var reminders = reminders return Section { Toggle(isOn: $reminders.issueAlertsEnabled) { - Label(Strings.settingsIssueAlertsToggle, systemImage: "checklist.checked") + Label(.settingsIssueAlertsToggle, systemImage: "checklist.checked") } if reminders.issueAlertsEnabled, !reminders.notificationsAuthorized { Button { openSystemSettings() } label: { - Label(Strings.settingsRemindersOpenSettings, systemImage: "bell.slash") + Label(.settingsRemindersOpenSettings, systemImage: "bell.slash") } } } header: { - Text(Strings.settingsIssueAlertsHeader) + Text(.settingsIssueAlertsHeader) } footer: { Text(issueAlertsFooter) } @@ -264,23 +267,26 @@ struct SettingsView: View { private var issueAlertsFooter: String { if reminders.issueAlertsEnabled, !reminders.notificationsAuthorized { - return Strings.settingsIssueAlertsDeniedFooter + return String(localized: .settingsIssueAlertsDeniedFooter) } - return Strings.settingsIssueAlertsFooter + return String(localized: .settingsIssueAlertsFooter) } private var resolutionSection: some View { @Bindable var report = report return Section { - Picker(Strings.settingsResolutionHeader, selection: $report.driftThreshold) { + Picker( + String(localized: .settingsResolutionHeader), + selection: $report.driftThreshold, + ) { ForEach(DriftThreshold.allCases, id: \.self) { threshold in - Text(Strings.driftThresholdLabel(kilometers: threshold.rawValue / 1000)) + Text(WhereFormat.driftThreshold(kilometers: threshold.rawValue / 1000)) .tag(threshold) } } } footer: { - Text(Strings.settingsResolutionFooter) + Text(.settingsResolutionFooter) } } @@ -288,12 +294,12 @@ struct SettingsView: View { @Bindable var report = report return Section { Toggle(isOn: $report.hideEmptyTabs) { - Label(Strings.settingsTabsToggle, systemImage: "rectangle.bottomthird.inset.filled") + Label(.settingsTabsToggle, systemImage: "rectangle.bottomthird.inset.filled") } } header: { - Text(Strings.settingsTabsHeader) + Text(.settingsTabsHeader) } footer: { - Text(Strings.settingsTabsFooter) + Text(.settingsTabsFooter) } } @@ -302,12 +308,12 @@ struct SettingsView: View { Button { showAppIcon = true } label: { - Label(Strings.settingsAppIconLink, systemImage: "app.badge") + Label(.settingsAppIconLink, systemImage: "app.badge") } } header: { - Text(Strings.settingsAppIconHeader) + Text(.settingsAppIconHeader) } footer: { - Text(Strings.settingsAppIconFooter) + Text(.settingsAppIconFooter) } } @@ -316,12 +322,12 @@ struct SettingsView: View { NavigationLink { ManualDayEntryView(report: report) } label: { - Label(Strings.settingsManualLink, systemImage: "calendar.badge.plus") + Label(.settingsManualLink, systemImage: "calendar.badge.plus") } } header: { - Text(Strings.settingsManualHeader) + Text(.settingsManualHeader) } footer: { - Text(Strings.settingsManualFooter) + Text(.settingsManualFooter) } } @@ -332,9 +338,9 @@ struct SettingsView: View { // progress), so no custom `UIActivityViewController` is needed. ShareLink( item: backupArchiveFile, - preview: SharePreview(Strings.settingsBackupShareTitle), + preview: SharePreview(String(localized: .settingsBackupShareTitle)), ) { - Label(Strings.settingsBackupExport, systemImage: "square.and.arrow.up") + Label(.settingsBackupExport, systemImage: "square.and.arrow.up") } .disabled(backup.backupState != .idle) @@ -344,14 +350,14 @@ struct SettingsView: View { if backup.backupState == .importing { importProgressLabel } else { - Label(Strings.settingsBackupImport, systemImage: "square.and.arrow.down") + Label(.settingsBackupImport, systemImage: "square.and.arrow.down") } } .disabled(backup.backupState != .idle) } header: { - Text(Strings.settingsBackupHeader) + Text(.settingsBackupHeader) } footer: { - Text(Strings.settingsBackupFooter) + Text(.settingsBackupFooter) } } @@ -359,7 +365,7 @@ struct SettingsView: View { /// `backup.backupProgress` as the backup coordinator writes each row. private var importProgressLabel: some View { VStack(alignment: .leading, spacing: 4) { - Label(Strings.settingsBackupImporting, systemImage: "square.and.arrow.down") + Label(.settingsBackupImporting, systemImage: "square.and.arrow.down") ProgressView(value: backup.backupProgress) } } @@ -411,19 +417,19 @@ struct SettingsView: View { Button(eraseTitle, role: .destructive) { Task { await report.clearSelectedYear() } } - Button(Strings.settingsDataCancel, role: .cancel) {} + Button(.settingsDataCancel, role: .cancel) {} } message: { - Text(Strings.settingsDataConfirmMessage(year: report.selectedYear)) + Text(.settingsDataConfirmMessage(WhereFormat.year(report.selectedYear))) } } header: { - Text(Strings.settingsDataHeader) + Text(.settingsDataHeader) } footer: { - Text(Strings.settingsDataFooter(year: report.selectedYear)) + Text(.settingsDataFooter(WhereFormat.year(report.selectedYear))) } } private var eraseTitle: String { - Strings.settingsDataErase(year: report.selectedYear) + String(localized: .settingsDataErase(WhereFormat.year(report.selectedYear))) } /// Whole-app teardown: wipes every year's data and returns to first-run @@ -435,22 +441,22 @@ struct SettingsView: View { Button(role: .destructive) { showResetConfirmation = true } label: { - Label(Strings.settingsResetErase, systemImage: "arrow.counterclockwise") + Label(.settingsResetErase, systemImage: "arrow.counterclockwise") } .confirmationDialog( - Strings.settingsResetErase, + String(localized: .settingsResetErase), isPresented: $showResetConfirmation, titleVisibility: .visible, ) { - Button(Strings.settingsResetConfirm, role: .destructive) { + Button(.settingsResetConfirm, role: .destructive) { requestReset() } - Button(Strings.settingsDataCancel, role: .cancel) {} + Button(.settingsDataCancel, role: .cancel) {} } message: { - Text(Strings.settingsResetMessage) + Text(.settingsResetMessage) } } footer: { - Text(Strings.settingsResetFooter) + Text(.settingsResetFooter) } } diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift deleted file mode 100644 index 779d1587..00000000 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ /dev/null @@ -1,1733 +0,0 @@ -import Foundation -import RegionKit -import WhereCore - -/// Localized, catalog-backed strings for WhereUI. -/// -/// Every user-facing string in the module is funneled through here so the -/// views stay free of literals and so lookups resolve against the module's -/// `Resources/Localizable.xcstrings` (`bundle: .module`). Counts use the -/// catalog's plural variations; years are formatted with a grouping-free -/// number style so they read "2026", never "2,026". -enum Strings { - // MARK: Tabs - - static var tabPrimary: String { - localized("tab.primary") - } - - static var tabElsewhere: String { - localized("tab.elsewhere") - } - - static var tabResolution: String { - localized("tab.resolution") - } - - static var tabSettings: String { - localized("tab.settings") - } - - // MARK: Shared - - static var loadErrorTitle: String { - localized("common.loadError.title") - } - - static var commonOK: String { - localized("common.ok") - } - - static var commonDone: String { - localized("common.done") - } - - static var commonCancel: String { - String(localized: "common.cancel", defaultValue: "Cancel", bundle: .module) - } - - /// "1 day" / "5 days" — with the count rendered. - static func dayCount(_ count: Int) -> String { - String(localized: "common.dayCount", defaultValue: "\(count) days", bundle: .module) - } - - /// "day" / "days" — the bare unit, when the count is shown separately. - static func dayUnit(_ count: Int) -> String { - count == 1 ? localized("common.day") : localized("common.days") - } - - static func regionDaysAccessibility(region: String, days: Int) -> String { - String( - localized: "common.regionDays.accessibility", - defaultValue: "\(region): \(dayCount(days))", - bundle: .module, - ) - } - - // MARK: Primary - - static var primaryTimeline: String { - localized("primary.timeline") - } - - static var primaryRecentActivity: String { - String( - localized: "primary.recentActivity", - defaultValue: "Recent activity", - bundle: .module, - ) - } - - /// Accessibility hint on a Primary region card: tapping it opens that - /// region's calendar, filtered to the days spent there. - static var primaryCardCalendarHint: String { - String( - localized: "primary.card.calendarHint", - defaultValue: "Opens the calendar of days spent here.", - bundle: .module, - ) - } - - static var primaryLoading: String { - localized("primary.loading") - } - - static var primaryEmptyDescription: String { - localized("primary.empty.description") - } - - static var primaryElsewhereOnlyTitle: String { - localized("primary.elsewhereOnly.title") - } - - /// Shown when there's tracked data, but none of it lands in a primary - /// region — points the user at the Elsewhere tab. - static func primaryElsewhereOnlyDescription(count: Int) -> String { - String( - localized: "primary.elsewhereOnly.description", - defaultValue: "\(count) days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", - bundle: .module, - ) - } - - static func primaryEmptyTitle(year: Int) -> String { - String( - localized: "primary.empty.title", - defaultValue: "No travels logged for \(yearText(year))", - bundle: .module, - ) - } - - // MARK: Elsewhere - - static var secondaryTitle: String { - localized("secondary.title") - } - - static var secondaryLoading: String { - localized("secondary.loading") - } - - static var secondaryEmptyTitle: String { - localized("secondary.empty.title") - } - - static var secondaryEmptyDescription: String { - localized("secondary.empty.description") - } - - static var secondaryCaptionPassingThrough: String { - localized("secondary.caption.passingThrough") - } - - static func secondaryHeader(year: Int) -> String { - String( - localized: "secondary.header", - defaultValue: "Everywhere else you turned up in \(yearText(year)).", - bundle: .module, - ) - } - - // MARK: Elsewhere region detail - - static var secondaryRegionFooter: String { - String( - localized: "secondary.region.footer", - defaultValue: "Tap a day to fix where it counted. Your GPS data stays untouched.", - bundle: .module, - ) - } - - static var secondaryRegionEmptyTitle: String { - String( - localized: "secondary.region.empty.title", - defaultValue: "Nothing to fix", - bundle: .module, - ) - } - - static var secondaryRegionEmptyDescription: String { - String( - localized: "secondary.region.empty.description", - defaultValue: "No days counted for this region.", - bundle: .module, - ) - } - - /// Caption on a day row showing the regions it currently counts for, e.g. - /// "Counts as California, New York". - static func secondaryRegionCurrent(regions: String) -> String { - String( - localized: "secondary.region.current", - defaultValue: "Counts as \(regions)", - bundle: .module, - ) - } - - /// Accessibility label for the map of recorded points on the region - /// drill-in. - static var secondaryRegionMapAccessibility: String { - String( - localized: "secondary.region.map.accessibility", - defaultValue: "Map of where you were", - bundle: .module, - ) - } - - // MARK: Relabel - - static var relabelTitle: String { - String(localized: "relabel.title", defaultValue: "Fix this day", bundle: .module) - } - - static var relabelRegionsHeader: String { - String( - localized: "relabel.regions.header", - defaultValue: "Where were you?", - bundle: .module, - ) - } - - static var relabelRegionsFooter: String { - String( - localized: "relabel.regions.footer", - defaultValue: "This replaces what was recorded for this day, overriding GPS. Your raw location data is kept, so you can change it back.", - bundle: .module, - ) - } - - static var relabelReset: String { - String( - localized: "relabel.reset", - defaultValue: "Reset to GPS-detected location", - bundle: .module, - ) - } - - static var relabelResetFooter: String { - String( - localized: "relabel.reset.footer", - defaultValue: "Removes any manual correction for this day and restores the regions detected from GPS. Your raw location data is untouched.", - bundle: .module, - ) - } - - // MARK: Manual-entry audit (read-back) - - static var auditHeader: String { - String(localized: "audit.header", defaultValue: "Entry record", bundle: .module) - } - - static var auditRecordedAt: String { - String(localized: "audit.recordedAt", defaultValue: "Recorded", bundle: .module) - } - - static var auditNote: String { - String(localized: "audit.note", defaultValue: "Note", bundle: .module) - } - - static var auditLocation: String { - String(localized: "audit.location", defaultValue: "Made at", bundle: .module) - } - - static var auditLocationUnavailable: String { - String( - localized: "audit.location.unavailable", - defaultValue: "Not captured", - bundle: .module, - ) - } - - /// A "37.77490, -122.41940"-style coordinate label. No catalog entry is - /// needed; the number style is locale-driven, like `driftThresholdLabel`. - static func auditCoordinate(latitude: Double, longitude: Double) -> String { - let lat = latitude.formatted(.number.precision(.fractionLength(5))) - let lon = longitude.formatted(.number.precision(.fractionLength(5))) - return "\(lat), \(lon)" - } - - // MARK: Onboarding - - static var onboardingWelcomeTitle: String { - String( - localized: "onboarding.welcome.title", - defaultValue: "Where have you been?", - bundle: .module, - ) - } - - static var onboardingWelcomeDescription: String { - String( - localized: "onboarding.welcome.description", - defaultValue: "Where keeps a private passport of which regions you spend your days in — built for residency and day-count questions.", - bundle: .module, - ) - } - - static var onboardingAutomaticTitle: String { - String( - localized: "onboarding.automatic.title", - defaultValue: "It logs itself", - bundle: .module, - ) - } - - static var onboardingAutomaticDescription: String { - String( - localized: "onboarding.automatic.description", - defaultValue: "With background location, Where quietly notes the regions you pass through. You can always add or correct days by hand.", - bundle: .module, - ) - } - - static var onboardingPrivacyTitle: String { - String( - localized: "onboarding.privacy.title", - defaultValue: "Private by design", - bundle: .module, - ) - } - - static var onboardingPrivacyDescription: String { - String( - localized: "onboarding.privacy.description", - defaultValue: "Your location stays on your device and in your own iCloud. Turn on background location to start your passport.", - bundle: .module, - ) - } - - static var onboardingContinue: String { - String(localized: "onboarding.continue", defaultValue: "Continue", bundle: .module) - } - - static var onboardingEnableLocation: String { - String( - localized: "onboarding.enableLocation", - defaultValue: "Enable Location", - bundle: .module, - ) - } - - static var onboardingNotNow: String { - String(localized: "onboarding.notNow", defaultValue: "Not Now", bundle: .module) - } - - // MARK: Migration - - static var migrationTitle: String { - String( - localized: "migration.title", - defaultValue: "Updating your data…", - bundle: .module, - ) - } - - static var migrationSubtitle: String { - String( - localized: "migration.subtitle", - defaultValue: "This only takes a moment.", - bundle: .module, - ) - } - - // MARK: Launch - - /// Spoken by VoiceOver while the launch splash is on screen (the icon and - /// radar animation are decorative and hidden from accessibility). - static var launchAccessibilityLabel: String { - String( - localized: "launch.accessibilityLabel", - defaultValue: "Loading", - bundle: .module, - ) - } - - // MARK: Settings - - static var settingsTitle: String { - localized("settings.title") - } - - static var settingsPermissionAlertTitle: String { - localized("settings.permissionAlert.title") - } - - static var settingsPermissionAlertMessage: String { - localized("settings.permissionAlert.message") - } - - static var settingsPermissionAlertOpenSettings: String { - localized("settings.permissionAlert.openSettings") - } - - static var settingsPermissionAlertNotNow: String { - localized("settings.permissionAlert.notNow") - } - - static var settingsLocationHeader: String { - localized("settings.location.header") - } - - static var settingsLocationToggle: String { - localized("settings.location.toggle") - } - - static var settingsLocationGrant: String { - localized("settings.location.grant") - } - - static var settingsLocationFooter: String { - localized("settings.location.footer") - } - - static var settingsStatusTracking: String { - localized("settings.status.tracking") - } - - static var settingsStatusAlwaysPaused: String { - localized("settings.status.alwaysPaused") - } - - static var settingsStatusWhenInUse: String { - localized("settings.status.whenInUse") - } - - static var settingsStatusNotDetermined: String { - localized("settings.status.notDetermined") - } - - static var settingsStatusDenied: String { - localized("settings.status.denied") - } - - static var settingsStatusRestricted: String { - localized("settings.status.restricted") - } - - static var settingsManualHeader: String { - localized("settings.manual.header") - } - - static var settingsManualLink: String { - localized("settings.manual.link") - } - - static var settingsManualFooter: String { - localized("settings.manual.footer") - } - - // MARK: Settings app icon - - static var settingsAppIconHeader: String { - String(localized: "settings.appIcon.header", defaultValue: "Appearance", bundle: .module) - } - - static var settingsAppIconLink: String { - String(localized: "settings.appIcon.link", defaultValue: "App icon", bundle: .module) - } - - static var settingsAppIconFooter: String { - String( - localized: "settings.appIcon.footer", - defaultValue: "Pick the icon Where shows on your Home Screen.", - bundle: .module, - ) - } - - // MARK: Developer tools - - static var developerTitle: String { - localized("developer.title") - } - - static var developerLogsLink: String { - localized("developer.logsLink") - } - - static var developerFooter: String { - localized("developer.footer") - } - - static var developerLogsTitle: String { - localized("developer.logsTitle") - } - - static var developerInspectorLink: String { - localized("developer.inspectorLink") - } - - static var developerInspectorTitle: String { - localized("developer.inspectorTitle") - } - - static var developerRegionMapLink: String { - localized("developer.regionMapLink") - } - - /// Accessibility label for the floating, collapsed developer button. - static var developerButtonLabel: String { - localized("developer.button.label") - } - - /// Accessibility label for the developer panel's close control. - static var developerClose: String { - localized("developer.close") - } - - /// Accessibility label for growing the developer panel to full screen. - static var developerExpand: String { - localized("developer.expand") - } - - /// Accessibility label for shrinking the developer panel back to a - /// floating window. - static var developerCollapse: String { - localized("developer.collapse") - } - - // MARK: Region map (developer) - - static var regionMapTitle: String { - String(localized: "regionMap.title", defaultValue: "Region Map", bundle: .module) - } - - /// Accessibility label for the picker that switches the drawn geometry. - static var regionMapKindPicker: String { - String(localized: "regionMap.kind.picker", defaultValue: "Geometry", bundle: .module) - } - - static func regionMapKind(_ kind: RegionGeometryKind) -> String { - switch kind { - case .attribution: - String( - localized: "regionMap.kind.attribution", - defaultValue: "Attribution", - bundle: .module, - ) - case .source: - String(localized: "regionMap.kind.source", defaultValue: "Source", bundle: .module) - } - } - - static func regionMapKindFooter(_ kind: RegionGeometryKind) -> String { - switch kind { - case .attribution: - String( - localized: "regionMap.kind.attribution.footer", - defaultValue: "The simplified polygons the app uses to attribute coordinates today.", - bundle: .module, - ) - case .source: - String( - localized: "regionMap.kind.source.footer", - defaultValue: "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity.", - bundle: .module, - ) - } - } - - static var regionMapLegendHeader: String { - String(localized: "regionMap.legend.header", defaultValue: "Features", bundle: .module) - } - - static var regionMapShowAll: String { - String(localized: "regionMap.showAll", defaultValue: "Show all", bundle: .module) - } - - static var regionMapMapAccessibility: String { - String( - localized: "regionMap.map.accessibility", - defaultValue: "Map of region boundaries", - bundle: .module, - ) - } - - static var regionMapLoadErrorTitle: String { - String( - localized: "regionMap.loadError.title", - defaultValue: "Couldn't load regions", - bundle: .module, - ) - } - - static var regionMapEmptyTitle: String { - String(localized: "regionMap.empty.title", defaultValue: "No regions", bundle: .module) - } - - static var regionMapEmptyDescription: String { - String( - localized: "regionMap.empty.description", - defaultValue: "No region geometry was found in the bundle.", - bundle: .module, - ) - } - - // MARK: App icon picker - - static var appIconTitle: String { - String(localized: "appIcon.title", defaultValue: "App Icon", bundle: .module) - } - - static var appIconCurrent: String { - String(localized: "appIcon.current", defaultValue: "Current", bundle: .module) - } - - static var appIconSet: String { - String(localized: "appIcon.set", defaultValue: "Set as App Icon", bundle: .module) - } - - static var appIconAppearanceLight: String { - String(localized: "appIcon.appearance.light", defaultValue: "Light", bundle: .module) - } - - static var appIconAppearanceDark: String { - String(localized: "appIcon.appearance.dark", defaultValue: "Dark", bundle: .module) - } - - static var appIconAppearanceHint: String { - String( - localized: "appIcon.appearance.hint", - defaultValue: "Tap the icon to preview light and dark.", - bundle: .module, - ) - } - - static var appIconErrorTitle: String { - String( - localized: "appIcon.error.title", - defaultValue: "Couldn't Change Icon", - bundle: .module, - ) - } - - static var settingsRemindersHeader: String { - String(localized: "settings.reminders.header", defaultValue: "Reminders", bundle: .module) - } - - static var settingsRemindersToggle: String { - String( - localized: "settings.reminders.toggle", - defaultValue: "Daily logging reminder", - bundle: .module, - ) - } - - static var settingsReminderTime: String { - String(localized: "settings.reminders.time", defaultValue: "Remind me at", bundle: .module) - } - - static var settingsRemindersFooter: String { - String( - localized: "settings.reminders.footer", - defaultValue: "If a day hasn't been logged, we'll nudge you before it ends and badge the app. The reminder clears itself once the day is recorded.", - bundle: .module, - ) - } - - static var settingsRemindersOpenSettings: String { - String( - localized: "settings.reminders.openSettings", - defaultValue: "Allow notifications", - bundle: .module, - ) - } - - static var settingsRemindersDeniedFooter: String { - String( - localized: "settings.reminders.deniedFooter", - defaultValue: "Notifications are turned off for Where, so reminders and the badge can't appear. Turn them on in Settings.", - bundle: .module, - ) - } - - static var settingsSummaryHeader: String { - String(localized: "settings.summary.header", defaultValue: "Daily summary", bundle: .module) - } - - static var settingsSummaryToggle: String { - String(localized: "settings.summary.toggle", defaultValue: "Daily summary", bundle: .module) - } - - static var settingsSummaryTime: String { - String(localized: "settings.summary.time", defaultValue: "Send at", bundle: .module) - } - - static var settingsSummaryFooter: String { - String( - localized: "settings.summary.footer", - defaultValue: "Get a morning recap of how many days you've logged in each region so far this year.", - bundle: .module, - ) - } - - static var settingsSummaryDeniedFooter: String { - String( - localized: "settings.summary.deniedFooter", - defaultValue: "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings.", - bundle: .module, - ) - } - - static var settingsIssueAlertsHeader: String { - String( - localized: "settings.issueAlerts.header", - defaultValue: "Issue alerts", - bundle: .module, - ) - } - - static var settingsIssueAlertsToggle: String { - String( - localized: "settings.issueAlerts.toggle", - defaultValue: "Issue alerts", - bundle: .module, - ) - } - - static var settingsIssueAlertsFooter: String { - String( - localized: "settings.issueAlerts.footer", - defaultValue: "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab.", - bundle: .module, - ) - } - - static var settingsIssueAlertsDeniedFooter: String { - String( - localized: "settings.issueAlerts.deniedFooter", - defaultValue: "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings.", - bundle: .module, - ) - } - - static var settingsTabsHeader: String { - String( - localized: "settings.tabs.header", - defaultValue: "Tabs", - bundle: .module, - ) - } - - static var settingsTabsToggle: String { - String( - localized: "settings.tabs.toggle", - defaultValue: "Hide empty tabs", - bundle: .module, - ) - } - - static var settingsTabsFooter: String { - String( - localized: "settings.tabs.footer", - defaultValue: "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar.", - bundle: .module, - ) - } - - static var settingsDataHeader: String { - localized("settings.data.header") - } - - static var settingsDataCancel: String { - localized("settings.data.cancel") - } - - static func settingsDataErase(year: Int) -> String { - String( - localized: "settings.data.erase", - defaultValue: "Erase \(yearText(year)) data", - bundle: .module, - ) - } - - static func settingsDataConfirmMessage(year: Int) -> String { - String( - localized: "settings.data.confirmMessage", - defaultValue: "This removes every sample, manual day, and piece of evidence in \(yearText(year)). It can't be undone.", - bundle: .module, - ) - } - - static func settingsDataFooter(year: Int) -> String { - String( - localized: "settings.data.footer", - defaultValue: "Acts on the year selected on the Primary tab (\(yearText(year))).", - bundle: .module, - ) - } - - static var settingsResetErase: String { - String( - localized: "settings.reset.erase", - defaultValue: "Erase all data & reset", - bundle: .module, - ) - } - - static var settingsResetConfirm: String { - String( - localized: "settings.reset.confirm", - defaultValue: "Erase Everything & Reset", - bundle: .module, - ) - } - - static var settingsResetMessage: String { - String( - localized: "settings.reset.message", - defaultValue: "This erases every sample, manual day, and piece of evidence on this device and returns you to first-run setup. It can't be undone.", - bundle: .module, - ) - } - - static var settingsResetFooter: String { - String( - localized: "settings.reset.footer", - defaultValue: "Starts over from scratch, as if you'd just installed Where.", - bundle: .module, - ) - } - - // MARK: Settings backup - - static var settingsBackupHeader: String { - localized("settings.backup.header") - } - - static var settingsBackupFooter: String { - localized("settings.backup.footer") - } - - static var settingsBackupExport: String { - localized("settings.backup.export") - } - - /// Title shown in the system share sheet preview for an exported backup. - static var settingsBackupShareTitle: String { - localized("settings.backup.shareTitle") - } - - static var settingsBackupImport: String { - localized("settings.backup.import") - } - - static var settingsBackupImporting: String { - localized("settings.backup.importing") - } - - static var settingsBackupErrorTitle: String { - localized("settings.backup.errorTitle") - } - - static var settingsBackupImportStrategyTitle: String { - localized("settings.backup.importStrategy.title") - } - - static var settingsBackupImportStrategyMessage: String { - localized("settings.backup.importStrategy.message") - } - - static var settingsBackupMerge: String { - localized("settings.backup.merge") - } - - static var settingsBackupReplace: String { - localized("settings.backup.replace") - } - - static var settingsBackupImportedTitle: String { - localized("settings.backup.imported.title") - } - - static func settingsBackupImportedMessage( - samples: Int, - evidence: Int, - manualDays: Int, - dismissedIssues: Int, - ) -> String { - String( - localized: "settings.backup.imported.message", - defaultValue: "Imported \(samples) location samples, \(evidence) pieces of evidence, \(manualDays) manual days, and \(dismissedIssues) dismissed issues.", - bundle: .module, - ) - } - - // MARK: Manual entry - - static var manualEntryPickerLabel: String { - localized("manual.entry.pickerLabel") - } - - static var manualModeSingleDay: String { - localized("manual.mode.singleDay") - } - - static var manualModeRange: String { - localized("manual.mode.range") - } - - static var manualDay: String { - localized("manual.day") - } - - static var manualFrom: String { - localized("manual.from") - } - - static var manualThrough: String { - localized("manual.through") - } - - static var manualSingleDayFooter: String { - localized("manual.singleDay.footer") - } - - static var manualRegionsHeader: String { - localized("manual.regions.header") - } - - static var manualRegionsFooter: String { - localized("manual.regions.footer") - } - - static var manualTitle: String { - localized("manual.title") - } - - static var manualSave: String { - localized("manual.save") - } - - static var manualSaveErrorTitle: String { - localized("manual.saveError.title") - } - - static var manualNoteHeader: String { - String(localized: "manual.note.header", defaultValue: "Reason", bundle: .module) - } - - static var manualNotePlaceholder: String { - String( - localized: "manual.note.placeholder", - defaultValue: "Add a note (optional)", - bundle: .module, - ) - } - - static var manualNoteFooter: String { - String( - localized: "manual.note.footer", - defaultValue: "Saved with this entry for auditing — explain why you made this change.", - bundle: .module, - ) - } - - static var manualSavingStatus: String { - String( - localized: "manual.saving.status", - defaultValue: "Capturing location…", - bundle: .module, - ) - } - - static func manualRangeFooter(count: Int) -> String { - String( - localized: "manual.range.footer", - defaultValue: "Backfilling \(count) days.", - bundle: .module, - ) - } - - // MARK: Calendar - - static var primaryCalendar: String { - String(localized: "primary.calendar", defaultValue: "Calendar", bundle: .module) - } - - static func calendarTitle(year: Int) -> String { - String( - localized: "calendar.title", - defaultValue: "Calendar · \(yearText(year))", - bundle: .module, - ) - } - - /// Title for the calendar when it's focused on a single region, e.g. - /// "California · 2026". - static func calendarRegionTitle(region: Region, year: Int) -> String { - String( - localized: "calendar.region.title", - defaultValue: "\(region.localizedName) · \(yearText(year))", - bundle: .module, - ) - } - - static var calendarUnavailableDescription: String { - String( - localized: "calendar.unavailable.description", - defaultValue: "Your year data isn't available right now.", - bundle: .module, - ) - } - - static func calendarDayAccessibility( - date: Date, - regions: [Region], - needsAttention: Bool, - hasEvidence: Bool, - ) -> String { - let base = calendarDayBase(date: date, regions: regions, needsAttention: needsAttention) - guard hasEvidence else { return base } - // Append the attachment cue so VoiceOver announces it after the day's - // regions/status, e.g. "Monday, March 4, California, has evidence". - return String( - localized: "calendar.day.hasEvidence.accessibility", - defaultValue: "\(base), has evidence", - bundle: .module, - ) - } - - private static func calendarDayBase( - date: Date, - regions: [Region], - needsAttention: Bool, - ) -> String { - let day = date.formatted(.dateTime.weekday(.wide).month(.wide).day()) - if needsAttention { - return String( - localized: "calendar.day.needsAttention.accessibility", - defaultValue: "\(day), needs a location", - bundle: .module, - ) - } - if regions.isEmpty { - return String( - localized: "calendar.day.empty.accessibility", - defaultValue: "\(day), nothing logged", - bundle: .module, - ) - } - let names = regions.map(\.localizedName).joined(separator: ", ") - return String( - localized: "calendar.day.accessibility", - defaultValue: "\(day), \(names)", - bundle: .module, - ) - } - - // MARK: Evidence - - /// Toolbar label + accessibility for the Primary tab's "view all evidence" - /// button. - static var primaryEvidence: String { - String(localized: "primary.evidence", defaultValue: "Evidence", bundle: .module) - } - - static func evidenceListTitle(year: Int) -> String { - String( - localized: "evidence.list.title", - defaultValue: "Evidence · \(yearText(year))", - bundle: .module, - ) - } - - static var evidenceEmptyTitle: String { - String(localized: "evidence.empty.title", defaultValue: "No evidence yet", bundle: .module) - } - - static var evidenceEmptyDescription: String { - String( - localized: "evidence.empty.description", - defaultValue: "Attach a boarding pass, receipt, or screenshot to back up where you were. Add one here, or share it into Where from another app.", - bundle: .module, - ) - } - - static var evidenceFailedTitle: String { - String( - localized: "evidence.failed.title", - defaultValue: "Couldn't load evidence", - bundle: .module, - ) - } - - /// Toolbar "+" label for adding a new piece of evidence in-app. - static var evidenceAdd: String { - String(localized: "evidence.add", defaultValue: "Add evidence", bundle: .module) - } - - /// Human-readable name for an evidence kind, used in the list rows, the - /// detail header, and the compose form's picker. `.other` shows its - /// user-supplied label when present, else a generic "Other". - static func evidenceKind(_ kind: EvidenceKind) -> String { - switch kind { - case .planeTicket: - return String( - localized: "evidence.kind.planeTicket", - defaultValue: "Plane ticket", - bundle: .module, - ) - case .boardingPass: - return String( - localized: "evidence.kind.boardingPass", - defaultValue: "Boarding pass", - bundle: .module, - ) - case .hotelReceipt: - return String( - localized: "evidence.kind.hotelReceipt", - defaultValue: "Hotel receipt", - bundle: .module, - ) - case .carRental: - return String( - localized: "evidence.kind.carRental", - defaultValue: "Car rental", - bundle: .module, - ) - case .rideshare: - return String( - localized: "evidence.kind.rideshare", - defaultValue: "Rideshare", - bundle: .module, - ) - case .photo: - return String( - localized: "evidence.kind.photo", - defaultValue: "Photo", - bundle: .module, - ) - case .document: - return String( - localized: "evidence.kind.document", - defaultValue: "Document", - bundle: .module, - ) - case .email: - return String( - localized: "evidence.kind.email", - defaultValue: "Email", - bundle: .module, - ) - case let .other(label): - if let label, !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return label - } - return String( - localized: "evidence.kind.other", - defaultValue: "Other", - bundle: .module, - ) - } - } - - static var evidenceKindPickerLabel: String { - String(localized: "evidence.form.kind", defaultValue: "Kind", bundle: .module) - } - - static var evidenceOtherLabelPlaceholder: String { - String(localized: "evidence.form.otherLabel", defaultValue: "Label", bundle: .module) - } - - static var evidenceDateLabel: String { - String(localized: "evidence.form.date", defaultValue: "Date", bundle: .module) - } - - static var evidenceNoteLabel: String { - String(localized: "evidence.form.note", defaultValue: "Note", bundle: .module) - } - - static var evidenceNotePlaceholder: String { - String( - localized: "evidence.form.notePlaceholder", - defaultValue: "Add a note (optional)", - bundle: .module, - ) - } - - static var evidenceAttachmentHeader: String { - String( - localized: "evidence.form.attachmentHeader", - defaultValue: "Attachment", - bundle: .module, - ) - } - - static var evidenceChooseFile: String { - String(localized: "evidence.form.chooseFile", defaultValue: "Choose file", bundle: .module) - } - - static var evidenceChoosePhoto: String { - String( - localized: "evidence.form.choosePhoto", - defaultValue: "Choose photo", - bundle: .module, - ) - } - - static var evidenceRemoveAttachment: String { - String( - localized: "evidence.form.remove", - defaultValue: "Remove attachment", - bundle: .module, - ) - } - - static var evidenceSave: String { - String(localized: "evidence.form.save", defaultValue: "Save", bundle: .module) - } - - static var evidenceSaveErrorTitle: String { - String( - localized: "evidence.form.saveError.title", - defaultValue: "Couldn't save evidence", - bundle: .module, - ) - } - - static var evidenceDetailNoteHeader: String { - String(localized: "evidence.detail.noteHeader", defaultValue: "Note", bundle: .module) - } - - static var evidenceNoPreviewTitle: String { - String( - localized: "evidence.detail.noPreview.title", - defaultValue: "No preview", - bundle: .module, - ) - } - - static var evidenceNoPreviewDescription: String { - String( - localized: "evidence.detail.noPreview.description", - defaultValue: "This attachment can't be previewed here.", - bundle: .module, - ) - } - - static var evidenceNoAttachment: String { - String( - localized: "evidence.detail.noAttachment", - defaultValue: "No attachment", - bundle: .module, - ) - } - - static var evidencePreviewFailed: String { - String( - localized: "evidence.detail.previewFailed", - defaultValue: "Couldn't load this attachment.", - bundle: .module, - ) - } - - /// Accessibility summary for a single evidence row: its kind and captured - /// date, e.g. "Plane ticket, March 4, 2026". - static func evidenceRowAccessibility(kind: EvidenceKind, date: Date) -> String { - let day = date.formatted(.dateTime.month(.wide).day().year()) - return String( - localized: "evidence.row.accessibility", - defaultValue: "\(evidenceKind(kind)), \(day)", - bundle: .module, - ) - } - - // MARK: Timeline - - static var timelineDone: String { - localized("timeline.done") - } - - static var timelineEmptyTitle: String { - localized("timeline.empty.title") - } - - static var timelineEmptyDescription: String { - localized("timeline.empty.description") - } - - static func timelineTitle(year: Int) -> String { - String( - localized: "timeline.title", - defaultValue: "Timeline · \(yearText(year))", - bundle: .module, - ) - } - - static func timelineRowAccessibility(region: String, range: String, days: Int) -> String { - String( - localized: "timeline.row.accessibility", - defaultValue: "\(region), \(range), \(dayCount(days))", - bundle: .module, - ) - } - - // MARK: Missing days - - static var missingDaysTitle: String { - String(localized: "missingDays.title", defaultValue: "Missing days", bundle: .module) - } - - static var missingDaysDone: String { - String(localized: "missingDays.done", defaultValue: "Done", bundle: .module) - } - - static var missingDaysHeader: String { - String(localized: "missingDays.header", defaultValue: "Days to backfill", bundle: .module) - } - - static var missingDaysFooter: String { - String( - localized: "missingDays.footer", - defaultValue: "Tap a stretch to record where you were. Today is included until something logs it.", - bundle: .module, - ) - } - - static var missingDaysEmptyTitle: String { - String(localized: "missingDays.empty.title", defaultValue: "All caught up", bundle: .module) - } - - static var missingDaysEmptyDescription: String { - String( - localized: "missingDays.empty.description", - defaultValue: "Every day this year has something logged.", - bundle: .module, - ) - } - - // MARK: Missing-day banner - - static func missingBannerCompact(count: Int) -> String { - if count == 1 { - String( - localized: "missing.banner.compact.one", - defaultValue: "1 day needs a location", - bundle: .module, - ) - } else { - String( - localized: "missing.banner.compact.other", - defaultValue: "\(count) days need a location", - bundle: .module, - ) - } - } - - static var missingBannerAccessibilityHint: String { - String( - localized: "missing.banner.accessibilityHint", - defaultValue: "Opens the list of days that still need logging.", - bundle: .module, - ) - } - - // MARK: Resolution - - static var resolutionTitle: String { - String(localized: "resolution.title", defaultValue: "Resolve", bundle: .module) - } - - static var resolutionEmptyTitle: String { - String(localized: "resolution.empty.title", defaultValue: "All clear", bundle: .module) - } - - static var resolutionEmptyDescription: String { - String( - localized: "resolution.empty.description", - defaultValue: "No data issues need your attention right now.", - bundle: .module, - ) - } - - static var resolutionDismiss: String { - String(localized: "resolution.dismiss", defaultValue: "Dismiss", bundle: .module) - } - - static func resolutionSectionHeader(_ category: DataIssueCategory) -> String { - switch category { - case .missingDays: - String( - localized: "resolution.section.missingDays", - defaultValue: "Missing days", - bundle: .module, - ) - case .borderDrift: - String( - localized: "resolution.section.borderDrift", - defaultValue: "Near the border", - bundle: .module, - ) - case .abruptChange: - String( - localized: "resolution.section.abruptChange", - defaultValue: "Sudden moves", - bundle: .module, - ) - } - } - - static func driftRowSubtitle(region: String, distance: String) -> String { - String( - localized: "resolution.drift.subtitle", - defaultValue: "Looks like \(region), ~\(distance) over the border", - bundle: .module, - ) - } - - static func resolutionAbruptRowTitle(earlier: Set, later: Set) -> String { - let earlierNames = earlier.map(\.localizedName).sorted().joined(separator: ", ") - let laterNames = later.map(\.localizedName).sorted().joined(separator: ", ") - return String( - localized: "resolution.abrupt.rowTitle", - defaultValue: "\(earlierNames) → \(laterNames)", - bundle: .module, - ) - } - - static var resolutionAbruptDetailTitle: String { - String( - localized: "resolution.abrupt.detail.title", - defaultValue: "Sudden location change", - bundle: .module, - ) - } - - static var resolutionAbruptDetailExplanation: String { - String( - localized: "resolution.abrupt.detail.explanation", - defaultValue: - "These back-to-back days don't overlap at all — you were probably traveling and one day wasn't logged in both places.", - bundle: .module, - ) - } - - static var resolutionAbruptDetailEarlierHeader: String { - String( - localized: "resolution.abrupt.detail.earlier", - defaultValue: "Earlier day", - bundle: .module, - ) - } - - static var resolutionAbruptDetailLaterHeader: String { - String( - localized: "resolution.abrupt.detail.later", - defaultValue: "Later day", - bundle: .module, - ) - } - - static var resolutionAbruptDetailRelabelEarlier: String { - String( - localized: "resolution.abrupt.detail.relabelEarlier", - defaultValue: "Mark as a travel day", - bundle: .module, - ) - } - - static var resolutionAbruptDetailRelabelLater: String { - String( - localized: "resolution.abrupt.detail.relabelLater", - defaultValue: "Mark as a travel day", - bundle: .module, - ) - } - - static var resolutionAbruptDetailBothRight: String { - String( - localized: "resolution.abrupt.detail.bothRight", - defaultValue: "These are both right", - bundle: .module, - ) - } - - static var settingsResolutionHeader: String { - String( - localized: "settings.resolution.header", - defaultValue: "Data resolution", - bundle: .module, - ) - } - - static var settingsResolutionFooter: String { - String( - localized: "settings.resolution.footer", - defaultValue: - "Days logged just outside a primary region within this distance are flagged as possible GPS drift.", - bundle: .module, - ) - } - - /// A localized "10 km"-style label for a drift-threshold preset. Formatted - /// through `Measurement` so the number and unit symbol localize, while - /// staying in kilometers (`.asProvided`, no conversion to miles) — the - /// presets are defined in km. No catalog entry is needed; the formatter is - /// locale-driven, like the date/number styles elsewhere in this file. - static func driftThresholdLabel(kilometers: Int) -> String { - Measurement(value: Double(kilometers), unit: UnitLength.kilometers) - .formatted(.measurement(width: .abbreviated, usage: .asProvided)) - } - - // MARK: Recent activity (24h on-device summary) - - /// The sheet title for the covered window (e.g. "Last 24 hours"). - static func recentActivityTitle(_ window: RecentActivityWindow) -> String { - switch window { - case .day: - String( - localized: "recentActivity.title.day", - defaultValue: "Last 24 hours", - bundle: .module, - ) - case .week: - String( - localized: "recentActivity.title.week", - defaultValue: "Past week", - bundle: .module, - ) - case .month: - String( - localized: "recentActivity.title.month", - defaultValue: "Past month", - bundle: .module, - ) - case .yearToDate: - String( - localized: "recentActivity.title.yearToDate", - defaultValue: "Year so far", - bundle: .module, - ) - } - } - - /// Accessibility label for the window segmented control (the visible - /// segments carry the individual window names). - static var recentActivityWindowPickerLabel: String { - String( - localized: "recentActivity.window.pickerLabel", - defaultValue: "Summary range", - bundle: .module, - ) - } - - /// Short segmented-control label for a window (e.g. "24 Hours", "Week"). - static func recentActivityWindowLabel(_ window: RecentActivityWindow) -> String { - switch window { - case .day: - String( - localized: "recentActivity.window.day", - defaultValue: "24 Hours", - bundle: .module, - ) - case .week: - String( - localized: "recentActivity.window.week", - defaultValue: "Week", - bundle: .module, - ) - case .month: - String( - localized: "recentActivity.window.month", - defaultValue: "Month", - bundle: .module, - ) - case .yearToDate: - String( - localized: "recentActivity.window.yearToDate", - defaultValue: "Year", - bundle: .module, - ) - } - } - - /// Privacy-forward footer describing what the covered window summarizes. - static func recentActivityFooter(_ window: RecentActivityWindow) -> String { - switch window { - case .day: - String( - localized: "recentActivity.footer.day", - defaultValue: "An on-device summary of where you've been in the last 24 hours. Your location never leaves your device.", - bundle: .module, - ) - case .week: - String( - localized: "recentActivity.footer.week", - defaultValue: "An on-device summary of where you've been over the past week. Your location never leaves your device.", - bundle: .module, - ) - case .month: - String( - localized: "recentActivity.footer.month", - defaultValue: "An on-device summary of where you've been over the past month. Your location never leaves your device.", - bundle: .module, - ) - case .yearToDate: - String( - localized: "recentActivity.footer.yearToDate", - defaultValue: "An on-device summary of where you've been so far this year. Your location never leaves your device.", - bundle: .module, - ) - } - } - - static var recentActivityLoading: String { - String( - localized: "recentActivity.loading", - defaultValue: "Summarizing…", - bundle: .module, - ) - } - - static var recentActivityRefresh: String { - String(localized: "recentActivity.refresh", defaultValue: "Refresh", bundle: .module) - } - - static var recentActivityEmptyTitle: String { - String( - localized: "recentActivity.empty.title", - defaultValue: "Nothing tracked", - bundle: .module, - ) - } - - /// Empty-state description naming the window that held no locations. - static func recentActivityEmptyDescription(_ window: RecentActivityWindow) -> String { - switch window { - case .day: - String( - localized: "recentActivity.empty.description.day", - defaultValue: "No locations were recorded in the last 24 hours.", - bundle: .module, - ) - case .week: - String( - localized: "recentActivity.empty.description.week", - defaultValue: "No locations were recorded in the past week.", - bundle: .module, - ) - case .month: - String( - localized: "recentActivity.empty.description.month", - defaultValue: "No locations were recorded in the past month.", - bundle: .module, - ) - case .yearToDate: - String( - localized: "recentActivity.empty.description.yearToDate", - defaultValue: "No locations were recorded so far this year.", - bundle: .module, - ) - } - } - - static var recentActivityFailedTitle: String { - String( - localized: "recentActivity.failed.title", - defaultValue: "Couldn't summarize", - bundle: .module, - ) - } - - static var recentActivityUnavailableTitle: String { - String( - localized: "recentActivity.unavailable.title", - defaultValue: "Summaries unavailable", - bundle: .module, - ) - } - - /// User-facing explanation for why the on-device model can't produce a - /// summary, keyed off the typed reason so the copy can guide the user. - static func recentActivityUnavailableMessage( - _ reason: ActivitySummaryUnavailableReason, - ) -> String { - switch reason { - case .deviceNotEligible: - String( - localized: "recentActivity.unavailable.deviceNotEligible", - defaultValue: "This device doesn't support on-device summaries.", - bundle: .module, - ) - case .appleIntelligenceNotEnabled: - String( - localized: "recentActivity.unavailable.appleIntelligenceNotEnabled", - defaultValue: "Turn on Apple Intelligence in Settings to generate summaries.", - bundle: .module, - ) - case .modelNotReady: - String( - localized: "recentActivity.unavailable.modelNotReady", - defaultValue: "The on-device model is still getting ready. Try again shortly.", - bundle: .module, - ) - case .unknown: - String( - localized: "recentActivity.unavailable.unknown", - defaultValue: "On-device summaries aren't available right now.", - bundle: .module, - ) - } - } - - // MARK: Widgets - - static var widgetTodayTitle: String { - String(localized: "widget.today.title", defaultValue: "Today", bundle: .module) - } - - static var widgetTodayEmpty: String { - String( - localized: "widget.today.empty", - defaultValue: "Nothing logged yet", - bundle: .module, - ) - } - - static func widgetYearTitle(year: Int) -> String { - String( - localized: "widget.year.title", - defaultValue: "Days in \(yearText(year))", - bundle: .module, - ) - } - - static var widgetYearEmpty: String { - String( - localized: "widget.year.empty", - defaultValue: "No days logged", - bundle: .module, - ) - } - - // MARK: Helpers - - private static func localized(_ key: String.LocalizationValue) -> String { - String(localized: key, bundle: .module) - } - - /// Year without a grouping separator ("2026", not "2,026"). - private static func yearText(_ year: Int) -> String { - year.formatted(.number.grouping(.never)) - } -} diff --git a/Where/WhereUI/Sources/Shared/WhereFormat.swift b/Where/WhereUI/Sources/Shared/WhereFormat.swift new file mode 100644 index 00000000..ffed6a36 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/WhereFormat.swift @@ -0,0 +1,189 @@ +import Foundation +import RegionKit +import WhereCore + +/// Formatting and presentation helpers that compose Xcode's generated String +/// Catalog symbols (`LocalizedStringResource`) with runtime values — counts, +/// years, region names, enum cases. +/// +/// Simple one-to-one strings are referenced through their generated symbols +/// directly at the call site (`Text(.tabPrimary)`, `String(localized: .commonOk)`); +/// only strings that need composition, pluralization, a `switch`, or non-catalog +/// number/coordinate formatting live here. Every catalog lookup below goes +/// through a generated symbol, so a removed or renamed key is a compile error. +enum WhereFormat { + // MARK: Numbers & measurements (non-catalog) + + /// Year without a grouping separator ("2026", not "2,026"). + static func year(_ year: Int) -> String { + year.formatted(.number.grouping(.never)) + } + + /// A localized "10 km"-style label for a drift-threshold preset. Formatted + /// through `Measurement` so the number and unit symbol localize, while + /// staying in kilometers (`.asProvided`, no conversion to miles) — the + /// presets are defined in km. + static func driftThreshold(kilometers: Int) -> String { + Measurement(value: Double(kilometers), unit: UnitLength.kilometers) + .formatted(.measurement(width: .abbreviated, usage: .asProvided)) + } + + /// A "37.77490, -122.41940"-style coordinate label. The number style is + /// locale-driven, so no catalog entry is needed. + static func coordinate(latitude: Double, longitude: Double) -> String { + let lat = latitude.formatted(.number.precision(.fractionLength(5))) + let lon = longitude.formatted(.number.precision(.fractionLength(5))) + return "\(lat), \(lon)" + } + + // MARK: Counts + + /// "1 day" / "5 days" — with the count rendered. + static func dayCount(_ count: Int) -> String { + String(localized: .commonDayCount(count)) + } + + /// "day" / "days" — the bare unit, when the count is shown separately. + static func dayUnit(_ count: Int) -> String { + count == 1 ? String(localized: .commonDay) : String(localized: .commonDays) + } + + static func missingBannerCompact(count: Int) -> String { + count == 1 + ? String(localized: .missingBannerCompactOne) + : String(localized: .missingBannerCompactOther(count)) + } + + // MARK: Accessibility (composed) + + static func regionDaysAccessibility(region: String, days: Int) -> String { + String(localized: .commonRegionDaysAccessibility(region, dayCount(days))) + } + + static func calendarDayAccessibility( + date: Date, + regions: [Region], + needsAttention: Bool, + hasEvidence: Bool, + ) -> String { + let base = calendarDayBase(date: date, regions: regions, needsAttention: needsAttention) + guard hasEvidence else { return base } + // Append the attachment cue so VoiceOver announces it after the day's + // regions/status, e.g. "Monday, March 4, California, has evidence". + return String(localized: .calendarDayHasEvidenceAccessibility(base)) + } + + private static func calendarDayBase( + date: Date, + regions: [Region], + needsAttention: Bool, + ) -> String { + let day = date.formatted(.dateTime.weekday(.wide).month(.wide).day()) + if needsAttention { + return String(localized: .calendarDayNeedsAttentionAccessibility(day)) + } + if regions.isEmpty { + return String(localized: .calendarDayEmptyAccessibility(day)) + } + let names = regions.map(\.localizedName).joined(separator: ", ") + return String(localized: .calendarDayAccessibility(day, names)) + } + + static func timelineRowAccessibility(region: String, range: String, days: Int) -> String { + String(localized: .timelineRowAccessibility(region, range, dayCount(days))) + } + + static func evidenceRowAccessibility(kind: EvidenceKind, date: Date) -> String { + let day = date.formatted(.dateTime.month(.wide).day().year()) + return String(localized: .evidenceRowAccessibility(kind.displayName, day)) + } + + // MARK: Resolution + + static func resolutionSectionHeader(_ category: DataIssueCategory) -> String { + switch category { + case .missingDays: String(localized: .resolutionSectionMissingDays) + case .borderDrift: String(localized: .resolutionSectionBorderDrift) + case .abruptChange: String(localized: .resolutionSectionAbruptChange) + } + } + + static func driftRowSubtitle(region: String, distance: String) -> String { + String(localized: .resolutionDriftSubtitle(region, distance)) + } + + static func resolutionAbruptRowTitle(earlier: Set, later: Set) -> String { + let earlierNames = earlier.map(\.localizedName).sorted().joined(separator: ", ") + let laterNames = later.map(\.localizedName).sorted().joined(separator: ", ") + return String(localized: .resolutionAbruptRowTitle(earlierNames, laterNames)) + } + + // MARK: Region map (developer) + + static func regionMapKind(_ kind: RegionGeometryKind) -> String { + switch kind { + case .attribution: String(localized: .regionMapKindAttribution) + case .source: String(localized: .regionMapKindSource) + } + } + + static func regionMapKindFooter(_ kind: RegionGeometryKind) -> String { + switch kind { + case .attribution: String(localized: .regionMapKindAttributionFooter) + case .source: String(localized: .regionMapKindSourceFooter) + } + } + + // MARK: Recent activity + + static func recentActivityTitle(_ window: RecentActivityWindow) -> String { + switch window { + case .day: String(localized: .recentActivityTitleDay) + case .week: String(localized: .recentActivityTitleWeek) + case .month: String(localized: .recentActivityTitleMonth) + case .yearToDate: String(localized: .recentActivityTitleYearToDate) + } + } + + static func recentActivityWindowLabel(_ window: RecentActivityWindow) -> String { + switch window { + case .day: String(localized: .recentActivityWindowDay) + case .week: String(localized: .recentActivityWindowWeek) + case .month: String(localized: .recentActivityWindowMonth) + case .yearToDate: String(localized: .recentActivityWindowYearToDate) + } + } + + static func recentActivityFooter(_ window: RecentActivityWindow) -> String { + switch window { + case .day: String(localized: .recentActivityFooterDay) + case .week: String(localized: .recentActivityFooterWeek) + case .month: String(localized: .recentActivityFooterMonth) + case .yearToDate: String(localized: .recentActivityFooterYearToDate) + } + } + + static func recentActivityEmptyDescription(_ window: RecentActivityWindow) -> String { + switch window { + case .day: String(localized: .recentActivityEmptyDescriptionDay) + case .week: String(localized: .recentActivityEmptyDescriptionWeek) + case .month: String(localized: .recentActivityEmptyDescriptionMonth) + case .yearToDate: String(localized: .recentActivityEmptyDescriptionYearToDate) + } + } + + static func recentActivityUnavailableMessage( + _ reason: ActivitySummaryUnavailableReason, + ) -> String { + switch reason { + case .deviceNotEligible: + String(localized: .recentActivityUnavailableDeviceNotEligible) + case .appleIntelligenceNotEnabled: + String(localized: .recentActivityUnavailableAppleIntelligenceNotEnabled) + case .modelNotReady: + String(localized: .recentActivityUnavailableModelNotReady) + case .unknown: + String(localized: .recentActivityUnavailableUnknown) + } + } +} diff --git a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift index 70793e63..5a690113 100644 --- a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift +++ b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift @@ -24,7 +24,7 @@ public struct TodayInlineAccessoryView: View { systemImage: first.style.symbolName, ) } else { - Label(Strings.widgetTodayEmpty, systemImage: "location.slash") + Label(.widgetTodayEmpty, systemImage: "location.slash") } } } @@ -60,7 +60,7 @@ public struct TodayCircularAccessoryView: View { } private var accessibilityText: String { - guard !regions.isEmpty else { return Strings.widgetTodayEmpty } + guard !regions.isEmpty else { return String(localized: .widgetTodayEmpty) } return regions.map(\.localizedName).joined(separator: ", ") } } diff --git a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift index 73dd426b..72444003 100644 --- a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift @@ -24,7 +24,7 @@ public struct TodayWidgetView: View { public var body: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.small) { HStack(alignment: .firstTextBaseline) { - Text(Strings.widgetTodayTitle) + Text(.widgetTodayTitle) .font(.caption2.weight(.semibold)) .textCase(.uppercase) .tracking(1) @@ -92,7 +92,7 @@ public struct TodayWidgetView: View { .font(.title3) .foregroundStyle(.tertiary) .accessibilityHidden(true) - Text(Strings.widgetTodayEmpty) + Text(.widgetTodayEmpty) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) } diff --git a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift index 8af74e24..5d41649d 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift @@ -22,7 +22,7 @@ public struct YearTotalsRectangularAccessoryView: View { public var body: some View { if ranked.isEmpty { - Label(Strings.widgetYearEmpty, systemImage: "calendar.badge.exclamationmark") + Label(.widgetYearEmpty, systemImage: "calendar.badge.exclamationmark") .font(.caption) } else { VStack(alignment: .leading, spacing: 0) { @@ -42,7 +42,7 @@ public struct YearTotalsRectangularAccessoryView: View { } .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.regionDaysAccessibility( + WhereFormat.regionDaysAccessibility( region: entry.region.localizedName, days: entry.days, ), diff --git a/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift b/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift index f447c6df..06af328f 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift @@ -24,7 +24,7 @@ public struct YearTotalsWidgetView: View { public var body: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.small) { - Text(Strings.widgetYearTitle(year: snapshot.year)) + Text(.widgetYearTitle(WhereFormat.year(snapshot.year))) .font(.caption2.weight(.semibold)) .textCase(.uppercase) .tracking(1) @@ -62,7 +62,7 @@ public struct YearTotalsWidgetView: View { } .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.regionDaysAccessibility( + WhereFormat.regionDaysAccessibility( region: entry.region.localizedName, days: entry.days, ), @@ -77,7 +77,7 @@ public struct YearTotalsWidgetView: View { .font(.title3) .foregroundStyle(.tertiary) .accessibilityHidden(true) - Text(Strings.widgetYearEmpty) + Text(.widgetYearEmpty) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) } diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift deleted file mode 100644 index 24b8bc13..00000000 --- a/Where/WhereUI/Tests/StringsTests.swift +++ /dev/null @@ -1,175 +0,0 @@ -import Foundation -import RegionKit -import Testing -import WhereCore -@testable import WhereUI - -/// Verifies the WhereUI string catalog is actually wired up (lookups resolve to -/// English values, not raw keys), that plural variations are honored, and that -/// years are formatted without a grouping separator. -struct StringsTests { - @Test func simpleKeysResolveToCatalogValues() { - #expect(Strings.tabElsewhere == "Elsewhere") - #expect(Strings.loadErrorTitle == "Couldn't load your year") - #expect(Strings.commonOK == "OK") - #expect(Strings.manualSaveErrorTitle == "Couldn't save that day") - #expect(Strings.primaryElsewhereOnlyTitle == "Nothing in your headline spots") - } - - @Test func elsewhereOnlyDescriptionUsesPluralVariations() { - #expect( - Strings.primaryElsewhereOnlyDescription(count: 1) - == - "1 day logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", - ) - #expect( - Strings.primaryElsewhereOnlyDescription(count: 9) - == - "9 days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", - ) - } - - @Test func dayCountUsesPluralVariations() { - #expect(Strings.dayCount(1) == "1 day") - #expect(Strings.dayCount(5) == "5 days") - } - - @Test func dayUnitUsesPluralVariations() { - #expect(Strings.dayUnit(1) == "day") - #expect(Strings.dayUnit(2) == "days") - } - - @Test func yearsAreFormattedWithoutGroupingSeparator() { - #expect(Strings.timelineTitle(year: 2026) == "Timeline · 2026") - #expect(Strings.calendarTitle(year: 2026) == "Calendar · 2026") - #expect(Strings.settingsDataErase(year: 2026) == "Erase 2026 data") - } - - @Test func calendarStringsResolveToCatalogValues() { - #expect(Strings.primaryCalendar == "Calendar") - #expect(Strings - .calendarUnavailableDescription == "Your year data isn't available right now.") - } - - @Test func calendarRegionTitleFormatsRegionAndGroupingFreeYear() { - #expect(Strings.calendarRegionTitle(region: .california, year: 2026) == "California · 2026") - } - - @Test func interpolatedStringsSubstituteArguments() { - #expect(Strings.primaryEmptyTitle(year: 2024) == "No travels logged for 2024") - } - - @Test func missingBannerCompactUsesPluralVariations() { - #expect(Strings.missingBannerCompact(count: 1) == "1 day needs a location") - #expect(Strings.missingBannerCompact(count: 8) == "8 days need a location") - } - - @Test func relabelStringsResolveToCatalogValues() { - #expect(Strings.relabelTitle == "Fix this day") - #expect(Strings.relabelRegionsHeader == "Where were you?") - #expect(Strings.relabelReset == "Reset to GPS-detected location") - #expect(Strings.secondaryRegionEmptyTitle == "Nothing to fix") - #expect(Strings.secondaryRegionCurrent(regions: "California") == "Counts as California") - } - - @Test func backupStringsResolveToCatalogValues() { - #expect(Strings.settingsBackupHeader == "Backup") - #expect(Strings.settingsBackupExport == "Export data") - #expect(Strings.settingsBackupImport == "Import data") - #expect(Strings.settingsBackupMerge == "Merge") - #expect(Strings.settingsBackupReplace == "Replace all") - #expect(Strings.settingsBackupImportedTitle == "Backup imported") - } - - @Test func backupImportedMessageSubstitutesAllCountsInOrder() { - #expect( - Strings.settingsBackupImportedMessage( - samples: 3, - evidence: 2, - manualDays: 5, - dismissedIssues: 4, - ) - == - "Imported 3 location samples, 2 pieces of evidence, 5 manual days, and 4 dismissed issues.", - ) - } - - @Test func evidenceStringsResolveToCatalogValues() { - #expect(Strings.primaryEvidence == "Evidence") - #expect(Strings.evidenceEmptyTitle == "No evidence yet") - #expect(Strings.evidenceAdd == "Add evidence") - #expect(Strings.evidenceListTitle(year: 2026) == "Evidence · 2026") - #expect(Strings.commonCancel == "Cancel") - } - - @Test func evidenceKindDisplayNamesResolve() { - #expect(Strings.evidenceKind(.planeTicket) == "Plane ticket") - #expect(Strings.evidenceKind(.boardingPass) == "Boarding pass") - #expect(Strings.evidenceKind(.email) == "Email") - #expect(Strings.evidenceKind(.other("Ferry ticket")) == "Ferry ticket") - #expect(Strings.evidenceKind(.other(nil)) == "Other") - } - - @Test func calendarDayAccessibilityAppendsEvidenceCue() throws { - let date = try #require(Calendar(identifier: .gregorian) - .date(from: DateComponents(year: 2026, month: 3, day: 4))) - let withEvidence = Strings.calendarDayAccessibility( - date: date, - regions: [.california], - needsAttention: false, - hasEvidence: true, - ) - let without = Strings.calendarDayAccessibility( - date: date, - regions: [.california], - needsAttention: false, - hasEvidence: false, - ) - #expect(withEvidence.hasSuffix("has evidence")) - #expect(!without.hasSuffix("has evidence")) - } - - @Test func developerToolsStringsResolveToCatalogValues() { - #expect(Strings.developerTitle == "Developer") - #expect(Strings.developerLogsLink == "Logs") - #expect(Strings.developerLogsTitle == "Logs") - #expect(Strings.developerInspectorLink == "SwiftData Inspector") - #expect(Strings.developerInspectorTitle == "SwiftData") - #expect(Strings.developerRegionMapLink == "Region map") - #expect( - Strings.developerFooter - == "On-device logs and data tools. Debug builds only.", - ) - } - - @Test func developerOverlayChromeStringsResolveToCatalogValues() { - #expect(Strings.developerButtonLabel == "Developer tools") - #expect(Strings.developerClose == "Close") - #expect(Strings.developerExpand == "Enter full screen") - #expect(Strings.developerCollapse == "Exit full screen") - } - - @Test func regionMapStringsResolveToCatalogValues() { - #expect(Strings.regionMapTitle == "Region Map") - #expect(Strings.regionMapKindPicker == "Geometry") - #expect(Strings.regionMapKind(.attribution) == "Attribution") - #expect(Strings.regionMapKind(.source) == "Source") - #expect(Strings.regionMapLegendHeader == "Features") - #expect(Strings.regionMapShowAll == "Show all") - #expect(Strings.regionMapMapAccessibility == "Map of region boundaries") - #expect(Strings.regionMapLoadErrorTitle == "Couldn't load regions") - #expect(Strings.regionMapEmptyTitle == "No regions") - #expect( - Strings.regionMapEmptyDescription == "No region geometry was found in the bundle.", - ) - #expect( - Strings.regionMapKindFooter(.attribution) - == "The simplified polygons the app uses to attribute coordinates today.", - ) - #expect( - Strings.regionMapKindFooter(.source) - == - "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity.", - ) - } -} diff --git a/Where/WhereUI/Tests/WhereFormatTests.swift b/Where/WhereUI/Tests/WhereFormatTests.swift new file mode 100644 index 00000000..cb7f709f --- /dev/null +++ b/Where/WhereUI/Tests/WhereFormatTests.swift @@ -0,0 +1,176 @@ +import Foundation +import RegionKit +import Testing +import WhereCore +@testable import WhereUI + +/// Verifies the WhereUI localization is wired up end to end: that generated +/// String Catalog symbols resolve to their English catalog values (not raw +/// keys), that plural variations are honored, and that `WhereFormat` composes +/// symbols with runtime values correctly (grouping-free years, counts, region +/// names, enum switches). +struct WhereFormatTests { + @Test func generatedSymbolsResolveToCatalogValues() { + #expect(String(localized: .tabElsewhere) == "Elsewhere") + #expect(String(localized: .commonLoadErrorTitle) == "Couldn't load your year") + #expect(String(localized: .commonOk) == "OK") + #expect(String(localized: .commonCancel) == "Cancel") + #expect(String(localized: .manualSaveErrorTitle) == "Couldn't save that day") + #expect(String(localized: .primaryElsewhereOnlyTitle) == "Nothing in your headline spots") + #expect(String(localized: .primaryCalendar) == "Calendar") + #expect( + String(localized: .calendarUnavailableDescription) + == "Your year data isn't available right now.", + ) + } + + @Test func pluralElsewhereDescriptionHonorsVariations() { + #expect( + String(localized: .primaryElsewhereOnlyDescription(1)) + == + "1 day logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + ) + #expect( + String(localized: .primaryElsewhereOnlyDescription(9)) + == + "9 days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + ) + } + + @Test func dayCountUsesPluralVariations() { + #expect(WhereFormat.dayCount(1) == "1 day") + #expect(WhereFormat.dayCount(5) == "5 days") + } + + @Test func dayUnitUsesPluralVariations() { + #expect(WhereFormat.dayUnit(1) == "day") + #expect(WhereFormat.dayUnit(2) == "days") + } + + @Test func missingBannerCompactUsesPluralVariations() { + #expect(WhereFormat.missingBannerCompact(count: 1) == "1 day needs a location") + #expect(WhereFormat.missingBannerCompact(count: 8) == "8 days need a location") + } + + @Test func yearsAreFormattedWithoutGroupingSeparator() { + #expect(WhereFormat.year(2026) == "2026") + #expect(String(localized: .timelineTitle(WhereFormat.year(2026))) == "Timeline · 2026") + #expect(String(localized: .calendarTitle(WhereFormat.year(2026))) == "Calendar · 2026") + #expect(String(localized: .settingsDataErase(WhereFormat.year(2026))) == "Erase 2026 data") + } + + @Test func calendarRegionTitleFormatsRegionAndGroupingFreeYear() { + #expect( + String(localized: .calendarRegionTitle( + Region.california.localizedName, + WhereFormat.year(2026), + )) + == "California · 2026", + ) + } + + @Test func interpolatedSymbolsSubstituteArguments() { + #expect(String(localized: .primaryEmptyTitle(WhereFormat.year(2024))) == + "No travels logged for 2024") + } + + @Test func relabelSymbolsResolveToCatalogValues() { + #expect(String(localized: .relabelTitle) == "Fix this day") + #expect(String(localized: .relabelRegionsHeader) == "Where were you?") + #expect(String(localized: .relabelReset) == "Reset to GPS-detected location") + #expect(String(localized: .secondaryRegionEmptyTitle) == "Nothing to fix") + #expect(String(localized: .secondaryRegionCurrent("California")) == "Counts as California") + } + + @Test func backupSymbolsResolveToCatalogValues() { + #expect(String(localized: .settingsBackupHeader) == "Backup") + #expect(String(localized: .settingsBackupExport) == "Export data") + #expect(String(localized: .settingsBackupImport) == "Import data") + #expect(String(localized: .settingsBackupMerge) == "Merge") + #expect(String(localized: .settingsBackupReplace) == "Replace all") + #expect(String(localized: .settingsBackupImportedTitle) == "Backup imported") + } + + @Test func backupImportedMessageSubstitutesAllCountsInOrder() { + #expect( + String(localized: .settingsBackupImportedMessage(3, 2, 5, 4)) + == + "Imported 3 location samples, 2 pieces of evidence, 5 manual days, and 4 dismissed issues.", + ) + } + + @Test func evidenceSymbolsResolveToCatalogValues() { + #expect(String(localized: .primaryEvidence) == "Evidence") + #expect(String(localized: .evidenceEmptyTitle) == "No evidence yet") + #expect(String(localized: .evidenceAdd) == "Add evidence") + #expect(String(localized: .evidenceListTitle(WhereFormat.year(2026))) == "Evidence · 2026") + } + + @Test func evidenceKindDisplayNamesResolve() { + #expect(EvidenceKind.planeTicket.displayName == "Plane ticket") + #expect(EvidenceKind.boardingPass.displayName == "Boarding pass") + #expect(EvidenceKind.email.displayName == "Email") + #expect(EvidenceKind.other("Ferry ticket").displayName == "Ferry ticket") + #expect(EvidenceKind.other(nil).displayName == "Other") + } + + @Test func calendarDayAccessibilityAppendsEvidenceCue() throws { + let date = try #require(Calendar(identifier: .gregorian) + .date(from: DateComponents(year: 2026, month: 3, day: 4))) + let withEvidence = WhereFormat.calendarDayAccessibility( + date: date, + regions: [.california], + needsAttention: false, + hasEvidence: true, + ) + let without = WhereFormat.calendarDayAccessibility( + date: date, + regions: [.california], + needsAttention: false, + hasEvidence: false, + ) + #expect(withEvidence.hasSuffix("has evidence")) + #expect(!without.hasSuffix("has evidence")) + } + + @Test func developerToolsSymbolsResolveToCatalogValues() { + #expect(String(localized: .developerTitle) == "Developer") + #expect(String(localized: .developerLogsLink) == "Logs") + #expect(String(localized: .developerLogsTitle) == "Logs") + #expect(String(localized: .developerInspectorLink) == "SwiftData Inspector") + #expect(String(localized: .developerInspectorTitle) == "SwiftData") + #expect(String(localized: .developerRegionMapLink) == "Region map") + #expect(String(localized: .developerFooter) == + "On-device logs and data tools. Debug builds only.") + } + + @Test func developerOverlayChromeSymbolsResolveToCatalogValues() { + #expect(String(localized: .developerButtonLabel) == "Developer tools") + #expect(String(localized: .developerClose) == "Close") + #expect(String(localized: .developerExpand) == "Enter full screen") + #expect(String(localized: .developerCollapse) == "Exit full screen") + } + + @Test func regionMapStringsResolveToCatalogValues() { + #expect(String(localized: .regionMapTitle) == "Region Map") + #expect(String(localized: .regionMapKindPicker) == "Geometry") + #expect(WhereFormat.regionMapKind(.attribution) == "Attribution") + #expect(WhereFormat.regionMapKind(.source) == "Source") + #expect(String(localized: .regionMapLegendHeader) == "Features") + #expect(String(localized: .regionMapShowAll) == "Show all") + #expect(String(localized: .regionMapMapAccessibility) == "Map of region boundaries") + #expect(String(localized: .regionMapLoadErrorTitle) == "Couldn't load regions") + #expect(String(localized: .regionMapEmptyTitle) == "No regions") + #expect(String(localized: .regionMapEmptyDescription) == + "No region geometry was found in the bundle.") + #expect( + WhereFormat.regionMapKindFooter(.attribution) + == "The simplified polygons the app uses to attribute coordinates today.", + ) + #expect( + WhereFormat.regionMapKindFooter(.source) + == + "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity.", + ) + } +} diff --git a/Where/WhereUI/Tests/WidgetViewsTests.swift b/Where/WhereUI/Tests/WidgetViewsTests.swift index 958c7b3b..66223d24 100644 --- a/Where/WhereUI/Tests/WidgetViewsTests.swift +++ b/Where/WhereUI/Tests/WidgetViewsTests.swift @@ -62,10 +62,10 @@ struct WidgetViewsTests { } @Test func widgetStringsResolve() { - #expect(Strings.widgetTodayTitle == "Today") - #expect(Strings.widgetTodayEmpty == "Nothing logged yet") - #expect(Strings.widgetYearTitle(year: 2026) == "Days in 2026") - #expect(Strings.widgetYearEmpty == "No days logged") + #expect(String(localized: .widgetTodayTitle) == "Today") + #expect(String(localized: .widgetTodayEmpty) == "Nothing logged yet") + #expect(String(localized: .widgetYearTitle(WhereFormat.year(2026))) == "Days in 2026") + #expect(String(localized: .widgetYearEmpty) == "No days logged") } // MARK: - Lock-screen accessories diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 7fc10db3..92c8e6c5 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -28,9 +28,10 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Read-only App Group access** — only the app writes `widget-snapshot.json`. - **No stale-day invalidation in the provider.** A snapshot whose `day` rolled past today is still shown until the app republishes — intentional. -- In-widget strings come from WhereUI `Strings`; gallery name/description from - this extension's own catalog. Widgets ship `#Preview` timelines like any - other WhereUI view. +- In-widget copy comes from the shared WhereUI content views; gallery + name/description use this extension's own generated String Catalog symbols + (`String(localized: .widgetGalleryTodayName)`). Widgets ship `#Preview` + timelines like any other WhereUI view. - **Seed the Broadway root via WhereUI's `whereBroadwayRoot()`** (applied in each widget's `StaticConfiguration` content) so the shared WhereUI views resolve trait-aware `@Environment(\.stylesheet)` tokens instead of `.default`. Do **not** diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 054c57d3..627cf3a1 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -33,11 +33,11 @@ app never wakes. ## Localization -- **In-widget copy** — resolved from [`WhereUI`](../WhereUI/)'s - `Localizable.xcstrings` (`Strings.widgetTodayTitle`, etc.). +- **In-widget copy** — rendered by the shared [`WhereUI`](../WhereUI/) content + views, which resolve their own generated String Catalog symbols. - **Widget gallery name/description** — resolved from this extension's [`Resources/Localizable.xcstrings`](Resources/Localizable.xcstrings) via - `WidgetStrings` (`bundle: .module`). + Xcode's generated symbols (`String(localized: .widgetGalleryTodayName)`). ## Installation diff --git a/Where/WhereWidgets/Resources/Localizable.xcstrings b/Where/WhereWidgets/Resources/Localizable.xcstrings index 0228fe6a..2acc1aea 100644 --- a/Where/WhereWidgets/Resources/Localizable.xcstrings +++ b/Where/WhereWidgets/Resources/Localizable.xcstrings @@ -3,7 +3,7 @@ "strings" : { "widget.gallery.today.description" : { "comment" : "Widget gallery description for the Today widget.", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -15,7 +15,7 @@ }, "widget.gallery.today.name" : { "comment" : "Widget gallery name for the Today widget.", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -27,7 +27,7 @@ }, "widget.gallery.yearTotals.description" : { "comment" : "Widget gallery description for the year totals widget.", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -39,7 +39,7 @@ }, "widget.gallery.yearTotals.name" : { "comment" : "Widget gallery name for the year totals widget.", - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { diff --git a/Where/WhereWidgets/Sources/TodayWidget.swift b/Where/WhereWidgets/Sources/TodayWidget.swift index 2d768715..5caf8350 100644 --- a/Where/WhereWidgets/Sources/TodayWidget.swift +++ b/Where/WhereWidgets/Sources/TodayWidget.swift @@ -18,8 +18,8 @@ struct TodayWidget: Widget { // no other Broadway root). .whereBroadwayRoot() } - .configurationDisplayName(WidgetStrings.todayGalleryName) - .description(WidgetStrings.todayGalleryDescription) + .configurationDisplayName(String(localized: .widgetGalleryTodayName)) + .description(String(localized: .widgetGalleryTodayDescription)) .supportedFamilies([.systemSmall, .accessoryInline, .accessoryCircular]) } } diff --git a/Where/WhereWidgets/Sources/WidgetStrings.swift b/Where/WhereWidgets/Sources/WidgetStrings.swift deleted file mode 100644 index b44bfa4c..00000000 --- a/Where/WhereWidgets/Sources/WidgetStrings.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -/// Widget gallery copy resolved from this extension's string catalog. -enum WidgetStrings { - static var todayGalleryName: String { - String(localized: "widget.gallery.today.name", defaultValue: "Today", bundle: .module) - } - - static var todayGalleryDescription: String { - String( - localized: "widget.gallery.today.description", - defaultValue: "Which region today counts for.", - bundle: .module, - ) - } - - static var yearTotalsGalleryName: String { - String( - localized: "widget.gallery.yearTotals.name", - defaultValue: "Day Counts", - bundle: .module, - ) - } - - static var yearTotalsGalleryDescription: String { - String( - localized: "widget.gallery.yearTotals.description", - defaultValue: "Days spent in each region this year.", - bundle: .module, - ) - } -} diff --git a/Where/WhereWidgets/Sources/YearTotalsWidget.swift b/Where/WhereWidgets/Sources/YearTotalsWidget.swift index 361c0339..e3957bc8 100644 --- a/Where/WhereWidgets/Sources/YearTotalsWidget.swift +++ b/Where/WhereWidgets/Sources/YearTotalsWidget.swift @@ -18,8 +18,8 @@ struct YearTotalsWidget: Widget { // no other Broadway root). .whereBroadwayRoot() } - .configurationDisplayName(WidgetStrings.yearTotalsGalleryName) - .description(WidgetStrings.yearTotalsGalleryDescription) + .configurationDisplayName(String(localized: .widgetGalleryYearTotalsName)) + .description(String(localized: .widgetGalleryYearTotalsDescription)) .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular]) } }