diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ec5ea99..37f611c 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -15,5 +15,8 @@ if [ -n "$staged" ]; then | xargs -0 git add fi +# Reconcile .xcstrings catalogs from Swift source, then re-stage any changes. +./localize --git-add + # Sync AGENTS.md + .agents/skills → CLAUDE.md + .claude/skills. ./sync-agents --git-add diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecd9864..8ce798b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: - uses: actions/checkout@v4 - uses: jdx/mise-action@v3 - run: ./swiftformat --lint + # Fail if any .xcstrings catalog has drifted from its Swift source of truth. + - run: ./localize --lint test: name: Build & Test diff --git a/AGENTS.md b/AGENTS.md index 12a34aa..3c98d58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ | SwiftFormat | 0.60.1 | `.mise.toml` | | Swift PM | 6.2 | `Package.swift` (`swift-tools-version`) | -**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles. +**Libraries** (**StuffCore**, **LocalizationKit**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **WhereCore**, **WhereUI**, **WhereTesting**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles. Tuist manifests live at the repo root ([`Project.swift`](Project.swift), [`Tuist.swift`](Tuist.swift)). `Project.swift` references `Package.local(path: .relativeToRoot("."))` and declares the **Where** app, **StuffTestHost**, and unit-test targets that depend on package products. @@ -20,8 +20,10 @@ project](#generating-the-xcode-project)). Root dev scripts: `ide`, `swiftformat` (runs SwiftFormat via mise), `sync-agents` (keeps Claude Code–oriented files in sync with `AGENTS.md`), `profile` (prints build/test hot spots — slowest build phases, slowest -tests, and slow type-check sites; see `./profile --help`), and `icons` -(adds/removes selectable app icons; see `./icons --help`). +tests, and slow type-check sites; see `./profile --help`), `icons` +(adds/removes selectable app icons; see `./icons --help`), and `localize` +(reconciles `.xcstrings` catalogs from Swift source; see [Keeping +localization in sync](#keeping-localization-in-sync)). ### Managing app icons @@ -45,12 +47,43 @@ appiconset is registered automatically — run `./ide --no-open` afterward to regenerate. The script's file edits work on Linux; compiling the catalogs is macOS-only. The primary "Classic" icon is reserved. +### Keeping localization in sync + +Swift is the single source of truth for user-facing strings. Framework types +live in [`LocalizationKit`](Shared/LocalizationKit/) (`LocalizedString`, +`LocalizationConfig`, `LocalizedString.catalog(_:_:bundle:)`, and the SwiftUI +helpers). Each module that ships a catalog declares keys in its own +`LocalizedStrings.swift` via a thin `.module(_:_:)` wrapper (delegating to +`.catalog(..., bundle: .module)`), and `./localize` (a build-free Ruby script, +like `sync-agents`) reconciles the sibling `Resources/Localizable.xcstrings` +so the two can't drift: + +- **add** keys present in Swift but missing from the catalog, +- **prune** catalog keys no longer referenced in Swift, +- **update** the English value of a simple key when its Swift default changed, +- **preserve** plural `variations`, parameterized keys, and every non-English + translation untouched (those stay hand-authored in the catalog / Xcode). + +```bash +./localize # reconcile + write all catalogs +./localize --lint # check only; non-zero exit on drift (CI) +./localize --git-add # reconcile, write, then re-stage (pre-commit hook) +``` + +The pre-commit hook runs `./localize --git-add` and the CI `format` job runs +`./localize --lint`. **WhereUI**, **WhereCore**, **LifecycleKit**, and +**WhereWidgets** each have a `LocalizedStrings.swift`; the script auto-discovers +every such file. No Tuist/Xcode build is involved, so it runs in the hook and +on Linux CI. + ## Formatting - **SwiftFormat** uses [`.swiftformat`](.swiftformat). Run `./swiftformat` to format the tree, or `./swiftformat --lint` to check only (as in CI). - The pre-commit hook (enabled by `./ide` via `core.hooksPath`) formats staged - `*.swift` files in place and re-stages them. + `*.swift` files in place and re-stages them, then runs `./localize --git-add` + (see [Keeping localization in sync](#keeping-localization-in-sync)) and + `./sync-agents --git-add`. ## Agent instructions sync @@ -68,8 +101,8 @@ by `./sync-agents`. ## Targets -- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/). -- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), and hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product. +- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/), Foundation-only placeholder), **LocalizationKit** ([`Shared/LocalizationKit/Sources/`](Shared/LocalizationKit/Sources/), deferred localization framework + SwiftUI helpers), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/). +- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), and hosted **\*Tests** bundles (**StuffCoreTests**, **LocalizationKitTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). ## Deployment diff --git a/Package.swift b/Package.swift index f14f07a..eb7eb68 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,7 @@ let package = Package( ], products: [ .library(name: "StuffCore", targets: ["StuffCore"]), + .library(name: "LocalizationKit", targets: ["LocalizationKit"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "LogKit", targets: ["LogKit"]), .library(name: "LogViewerUI", targets: ["LogViewerUI"]), @@ -25,8 +26,15 @@ let package = Package( name: "StuffCore", path: "Shared/StuffCore/Sources", ), + .target( + name: "LocalizationKit", + path: "Shared/LocalizationKit/Sources", + ), .target( name: "LifecycleKit", + dependencies: [ + .target(name: "LocalizationKit"), + ], path: "Shared/LifecycleKit/Sources", resources: [ .process("Resources"), @@ -50,6 +58,7 @@ let package = Package( .target( name: "WhereCore", dependencies: [ + .target(name: "LocalizationKit"), .target(name: "LogKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), ], @@ -61,6 +70,7 @@ let package = Package( .target( name: "WhereUI", dependencies: [ + .target(name: "LocalizationKit"), .target(name: "WhereCore"), .target(name: "LifecycleKit"), .target(name: "LogKit"), diff --git a/Project.swift b/Project.swift index f7028c3..911cc01 100644 --- a/Project.swift +++ b/Project.swift @@ -119,9 +119,10 @@ let project = Project( ]), ]), sources: ["Where/WhereWidgets/Sources/**"], - resources: ["Where/WhereWidgets/Resources/**"], + resources: ["Where/WhereWidgets/Sources/Resources/**"], entitlements: whereAppGroupEntitlements, dependencies: [ + .package(product: "LocalizationKit"), .package(product: "LogKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), @@ -180,6 +181,12 @@ let project = Project( productDependency: "StuffCore", sources: ["Shared/StuffCore/Tests/**"], ), + unitTests( + name: "LocalizationKitTests", + bundleIdSuffix: "localizationkit", + productDependency: "LocalizationKit", + sources: ["Shared/LocalizationKit/Tests/**"], + ), unitTests( name: "LifecycleKitTests", bundleIdSuffix: "lifecyclekit", @@ -215,7 +222,12 @@ let project = Project( bundleIdSuffix: "whereui", productDependency: "WhereUI", sources: ["Where/WhereUI/Tests/**"], - extraPackageProducts: ["LifecycleKit", "LogViewerUI", "SwiftDataInspector"], + extraPackageProducts: [ + "LocalizationKit", + "LifecycleKit", + "LogViewerUI", + "SwiftDataInspector", + ], ), ], // Tuist's autogeneration doesn't emit working standalone test actions for @@ -225,6 +237,7 @@ let project = Project( // target a single bundle without building the whole workspace. schemes: [ testScheme(name: "StuffCoreTests"), + testScheme(name: "LocalizationKitTests"), testScheme(name: "LifecycleKitTests"), testScheme(name: "LogKitTests"), testScheme(name: "LogViewerUITests"), diff --git a/Shared/LifecycleKit/AGENTS.md b/Shared/LifecycleKit/AGENTS.md index 76cf066..4a2d255 100644 --- a/Shared/LifecycleKit/AGENTS.md +++ b/Shared/LifecycleKit/AGENTS.md @@ -12,10 +12,10 @@ system, formatting, and global conventions. Read that first. ## Scope & dependencies -- Pure **SwiftUI + Foundation + Observation**. It must **not** import WhereCore, - UIKit, or any app code — it's a generic library that the Where app (and any - future app) adopts. Keep it that way: app-specific launch logic lives in the - consumer (e.g. `WhereUI/Sources/Launch/`), not here. +- Pure **SwiftUI + Foundation + Observation + LocalizationKit**. It must **not** + import WhereCore, UIKit, or any app code — it's a generic library that the + Where app (and any future app) adopts. Keep it that way: app-specific launch + logic lives in the consumer (e.g. `WhereUI/Sources/Launch/`), not here. - Library target only ([`Package.swift`](../../Package.swift), `Shared/LifecycleKit/Sources`); the hosted test bundle `LifecycleKitTests` is wired in [`Project.swift`](../../Project.swift) via the `unitTests` helper @@ -90,6 +90,9 @@ itself. destination (`content`, e.g. the app's `TabView`) is **not** a step — it's terminal and shown only at `.ready`. - [`LifecycleSplash`](Sources/LifecycleSplash.swift) – the default placeholder. +- [`LocalizedStrings`](Sources/LocalizedStrings.swift) – launch failure UI + copy (`failure.launch.*`), reconciled via `./localize` against + [`Resources/Localizable.xcstrings`](Sources/Resources/Localizable.xcstrings). ## Two invariants to preserve diff --git a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift b/Shared/LifecycleKit/Sources/LifecycleFailureView.swift index fb5468a..96b0909 100644 --- a/Shared/LifecycleKit/Sources/LifecycleFailureView.swift +++ b/Shared/LifecycleKit/Sources/LifecycleFailureView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI /// The UI shown when a launch step throws. Describes the failure and offers a @@ -14,13 +15,13 @@ public struct LifecycleFailureView: View { public var body: some View { ContentUnavailableView { Label( - String(localized: "failure.launch.title", bundle: .module), + LocalizedStrings.Failure.launchTitle.localized, systemImage: "exclamationmark.triangle", ) } description: { Text(failure.error.localizedDescription) } actions: { - Button(String(localized: "failure.launch.retry", bundle: .module), action: retry) + Button(LocalizedStrings.Failure.launchRetry.localized, action: retry) .buttonStyle(.borderedProminent) } } diff --git a/Shared/LifecycleKit/Sources/LocalizedString+Module.swift b/Shared/LifecycleKit/Sources/LocalizedString+Module.swift new file mode 100644 index 0000000..84dcda6 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LocalizedString+Module.swift @@ -0,0 +1,19 @@ +import LocalizationKit + +extension LocalizedString { + /// A LifecycleKit catalog string — delegates to ``LocalizedString/catalog(_:_:bundle:)`` + /// with `bundle: .module`. + static func module( + _ key: StaticString, + _ defaultValue: String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, defaultValue, bundle: .module) + } + + static func module( + _ key: StaticString, + _ defaultValue: @Sendable @escaping (LocalizationConfig?) -> String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, bundle: .module, defaultValue) + } +} diff --git a/Shared/LifecycleKit/Sources/LocalizedStrings.swift b/Shared/LifecycleKit/Sources/LocalizedStrings.swift new file mode 100644 index 0000000..3703418 --- /dev/null +++ b/Shared/LifecycleKit/Sources/LocalizedStrings.swift @@ -0,0 +1,20 @@ +import LocalizationKit + +/// Catalog-backed strings for LifecycleKit. +/// +/// Swift is the source of truth for keys and English defaults; the sibling +/// `Resources/Localizable.xcstrings` owns translations. The root `./localize` +/// script reconciles the catalog from this file. +enum LocalizedStrings { + enum Failure { + static let launchTitle: LocalizedString = .module( + "failure.launch.title", + "Couldn't finish launching", + ) + + static let launchRetry: LocalizedString = .module( + "failure.launch.retry", + "Try Again", + ) + } +} diff --git a/Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings b/Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings index c679902..f9c8866 100644 --- a/Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings +++ b/Shared/LifecycleKit/Sources/Resources/Localizable.xcstrings @@ -24,5 +24,5 @@ } } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Shared/LocalizationKit/AGENTS.md b/Shared/LocalizationKit/AGENTS.md new file mode 100644 index 0000000..b1de2a7 --- /dev/null +++ b/Shared/LocalizationKit/AGENTS.md @@ -0,0 +1,43 @@ +# LocalizationKit – Module Shape + +Framework-level localization tooling — **Foundation + SwiftUI**. Each app module +(WhereUI, WhereCore, …) depends on this for `LocalizedString` and the SwiftUI +helpers, then keeps its own `LocalizedStrings.swift` catalog and a thin +`.module(_:_:)` wrapper binding `Bundle.module`. + +Complements root [`AGENTS.md`](../../AGENTS.md). Tests: `LocalizationKitTests` +in `StuffTestHost` (`tuist test LocalizationKitTests`). + +## Key types + +- [`LocalizedString`](Sources/LocalizedString.swift) — a **deferred** localized + string. Wraps a `@Sendable (LocalizationConfig?) -> String` builder; + `.localized(_:)` runs it. `Sendable`, so a `LocalizedString` can be cached in a + `static let` and cross isolation boundaries. +- [`LocalizationConfig`](Sources/LocalizedString.swift) — `Sendable, Hashable` + value type carrying the `locale` to resolve against. `nil` means "process + default" (`.current`). +- [`LocalizedString.catalog(_:_:bundle:)`](Sources/LocalizedString+Catalog.swift) + — the generic factory. Each consumer module defines a thin `.module(_:_:)` + that passes `bundle: .module` (see WhereUI's `LocalizedString+Module.swift`). +- [`Text(localized:)`](Sources/Text+Localized.swift) and + [`View` overloads](Sources/View+Localized.swift) — resolve at display time. + +## Invariants / conventions + +- LocalizationKit ships **no catalog** — only the types and helpers. Keys and + English defaults live in each module's `LocalizedStrings.swift`; the root + `./localize` script parses literal `.module("", "")` factory calls + (and the closure overload) — see root + [`AGENTS.md`](../../AGENTS.md#keeping-localization-in-sync). +- Keep the key and default value as **string literals** (the key is a + `StaticString`) — anything dynamic makes the script fail loudly rather than + drift silently. +- Resolution is lazy: referencing a `LocalizedString` does no work; only + `.localized` reads the catalog. Don't cache resolved `String`s where a + locale override might later apply. + +## Testing + +Tests live in [`Tests/`](Tests) (Swift Testing only). The bundle runs in +`StuffTestHost`. See [`LocalizedStringTests`](Tests/LocalizedStringTests.swift). diff --git a/Shared/LocalizationKit/README.md b/Shared/LocalizationKit/README.md new file mode 100644 index 0000000..50a3d4f --- /dev/null +++ b/Shared/LocalizationKit/README.md @@ -0,0 +1,50 @@ +# LocalizationKit + +SwiftUI-capable SPM library for deferred, catalog-backed localization. It owns +the framework-level tooling; each app module keeps its own `LocalizedStrings` +enum and `Localizable.xcstrings` catalog. + +## Key types + +- **`LocalizedString`** — a user-facing string that hasn't been localized yet. + It wraps a `@Sendable` builder closure and resolves lazily when you call + `.localized(_:)`. Per-module `LocalizedStrings` enums return these instead of + `String`, deferring the catalog lookup to the point of display so a call site + can override the locale. It's `Sendable`, so producers can cache each string + in a `static let` and pass it across isolation boundaries. +- **`LocalizationConfig`** — a small value type (today just a `Locale`) passed + to `.localized(config)` to render against a locale other than the process + default. +- **`LocalizedString.catalog(_:_:bundle:)`** — the generic factory that performs + a `String(localized:bundle:locale:)` lookup. Each module wraps it in a thin + `.module(_:_:)` that passes `bundle: .module`. +- **`Text(localized:)`** and **`View` overloads** of `navigationTitle`, + `accessibilityLabel`, and `accessibilityHint` — resolve a `LocalizedString` + at the point of display. + +## Quick start + +```swift +import LocalizationKit + +// In each module's LocalizedString+Module.swift: +extension LocalizedString { + static func module(_ key: StaticString, _ value: String.LocalizationValue) -> LocalizedString { + .catalog(key, value, bundle: .module) + } +} + +// In LocalizedStrings.swift: +static let greeting: LocalizedString = .module("greeting", "Hello") + +// At a SwiftUI call site (with dot-syntax accessors on LocalizedString): +Text(localized: .primary.emptyDescription) +.navigationTitle(.settings.title) + +// Where a plain String is needed: +Button(LocalizedStrings.Common.done.localized) { … } +``` + +Add shared types under [`Sources/`](Sources/) and wire consumers in +[`Package.swift`](../../Package.swift). Run tests with +`tuist test LocalizationKitTests`. diff --git a/Shared/LocalizationKit/Sources/LocalizedString+Catalog.swift b/Shared/LocalizationKit/Sources/LocalizedString+Catalog.swift new file mode 100644 index 0000000..8ed7afa --- /dev/null +++ b/Shared/LocalizationKit/Sources/LocalizedString+Catalog.swift @@ -0,0 +1,45 @@ +import Foundation + +extension LocalizedString { + /// A catalog string: looks `key` up in `bundle`, falling back to + /// `defaultValue`, and honors an optional locale override at resolution + /// time. + /// + /// Each module wraps this in a thin `.module(_:_:)` factory that passes + /// `bundle: .module`. The key stays a `StaticString` literal on purpose: + /// that's the overload of `String(localized:)` that resolves plural + /// `variations`, and it keeps both Xcode's catalog extraction and the repo's + /// `./localize` script able to read every key statically. + public static func catalog( + _ key: StaticString, + _ defaultValue: String.LocalizationValue, + bundle: Bundle, + ) -> LocalizedString { + LocalizedString { + String( + localized: key, + defaultValue: defaultValue, + bundle: bundle, + locale: $0?.locale ?? .current, + ) + } + } + + /// Same as ``catalog(_:_:bundle:)``, but the default value is built from the + /// resolution config so a composed string can thread the locale override + /// into a nested `.localized($0)`. + public static func catalog( + _ key: StaticString, + bundle: Bundle, + _ defaultValue: @Sendable @escaping (LocalizationConfig?) -> String.LocalizationValue, + ) -> LocalizedString { + LocalizedString { + String( + localized: key, + defaultValue: defaultValue($0), + bundle: bundle, + locale: $0?.locale ?? .current, + ) + } + } +} diff --git a/Shared/LocalizationKit/Sources/LocalizedString.swift b/Shared/LocalizationKit/Sources/LocalizedString.swift new file mode 100644 index 0000000..c31983f --- /dev/null +++ b/Shared/LocalizationKit/Sources/LocalizedString.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Options that influence how a ``LocalizedString`` resolves. +/// +/// Today this only carries a `locale`, letting a caller render a string in a +/// locale other than the process default (e.g. a per-view override). It is a +/// value type so it can be threaded through SwiftUI without surprises. +public struct LocalizationConfig: Sendable, Hashable { + /// The locale to resolve the string against. + public var locale: Locale + + public init(locale: Locale) { + self.locale = locale + } +} + +/// A user-facing string that has **not been localized yet**. +/// +/// Producers (the per-module `LocalizedStrings` enums) return a +/// `LocalizedString` instead of a `String`, deferring the actual catalog +/// lookup until ``localized(_:)`` is called. Each instance wraps a builder +/// closure that performs a standard `String(localized:bundle:locale:)` lookup — +/// the closure captures any interpolated arguments, so parameterized and +/// pluralized strings need no special machinery here. +/// +/// Deferring resolution is what lets a call site override the locale (via +/// ``LocalizationConfig``) at the moment of display rather than at the moment +/// the string is referenced. +public struct LocalizedString: Sendable { + private let build: @Sendable (LocalizationConfig?) -> String + + /// Wrap a builder that resolves the string, optionally honoring an override + /// config. The builder should perform a `String(localized:)` lookup against + /// the owning module's catalog. + /// + /// The builder is `@Sendable` so a `LocalizedString` can be cached in a + /// `static let` and handed across isolation boundaries (e.g. a widget + /// timeline) without tripping Swift's concurrency checks. + public init(_ build: @Sendable @escaping (LocalizationConfig?) -> String) { + self.build = build + } + + /// Resolve the string, optionally overriding the locale via `config`. + public func localized(_ config: LocalizationConfig? = nil) -> String { + build(config) + } + + /// Resolve the string via `localized()` with the default `localized()` arguments. + public var localized: String { + localized() + } +} diff --git a/Shared/LocalizationKit/Sources/Text+Localized.swift b/Shared/LocalizationKit/Sources/Text+Localized.swift new file mode 100644 index 0000000..2b248be --- /dev/null +++ b/Shared/LocalizationKit/Sources/Text+Localized.swift @@ -0,0 +1,13 @@ +import SwiftUI + +extension Text { + /// Build a `Text` from a deferred ``LocalizedString``, resolving it at the + /// point of display — e.g. `Text(localized: .primary.emptyDescription)`. + /// + /// Prefer this over `Text(someString.localized)` at call sites so the + /// resolution seam stays in one place — it's where a future + /// Environment-driven locale override will read the locale from. + public init(localized string: LocalizedString, _ config: LocalizationConfig? = nil) { + self.init(string.localized(config)) + } +} diff --git a/Shared/LocalizationKit/Sources/View+Localized.swift b/Shared/LocalizationKit/Sources/View+Localized.swift new file mode 100644 index 0000000..537448b --- /dev/null +++ b/Shared/LocalizationKit/Sources/View+Localized.swift @@ -0,0 +1,34 @@ +import SwiftUI + +extension View { + /// Set the navigation title from a deferred ``LocalizedString``, resolving it + /// at the point of display. + /// + /// The `LocalizedString`-taking modifiers below mirror + /// ``SwiftUI/Text/init(localized:_:)``: they keep the resolution seam in one + /// place so a future Environment-driven locale override has a single place to + /// read the locale from. Prefer them over `someString.localized` at call + /// sites. + public func navigationTitle( + _ title: LocalizedString, + _ config: LocalizationConfig? = nil, + ) -> some View { + navigationTitle(title.localized(config)) + } + + /// Set the accessibility label from a deferred ``LocalizedString``. + public func accessibilityLabel( + _ label: LocalizedString, + _ config: LocalizationConfig? = nil, + ) -> some View { + accessibilityLabel(Text(label.localized(config))) + } + + /// Set the accessibility hint from a deferred ``LocalizedString``. + public func accessibilityHint( + _ hint: LocalizedString, + _ config: LocalizationConfig? = nil, + ) -> some View { + accessibilityHint(Text(hint.localized(config))) + } +} diff --git a/Shared/LocalizationKit/Tests/LocalizedStringTests.swift b/Shared/LocalizationKit/Tests/LocalizedStringTests.swift new file mode 100644 index 0000000..2543f29 --- /dev/null +++ b/Shared/LocalizationKit/Tests/LocalizedStringTests.swift @@ -0,0 +1,21 @@ +import Foundation +import LocalizationKit +import Testing + +struct LocalizedStringTests { + @Test func resolvesViaBuilder() { + let string = LocalizedString { _ in "hello" } + #expect(string.localized == "hello") + } + + @Test func defaultsToNilConfig() { + let string = LocalizedString { $0?.locale.identifier ?? "default" } + #expect(string.localized == "default") + } + + @Test func passesConfigToBuilder() { + let string = LocalizedString { $0?.locale.identifier ?? "default" } + let config = LocalizationConfig(locale: Locale(identifier: "fr_FR")) + #expect(string.localized(config) == "fr_FR") + } +} diff --git a/Shared/StuffCore/AGENTS.md b/Shared/StuffCore/AGENTS.md index 8017911..10b1cab 100644 --- a/Shared/StuffCore/AGENTS.md +++ b/Shared/StuffCore/AGENTS.md @@ -1,7 +1,18 @@ # StuffCore – Module Shape -Scaffold library — **Foundation only**, no app imports. Placeholder -[`StuffCore.version`](Sources/StuffCore.swift) until real shared API ships. +Shared library — **Foundation only**, no app/SwiftUI imports, so every module +(incl. `WhereCore`, which must not import SwiftUI directly) can depend on it. Complements root [`AGENTS.md`](../../AGENTS.md). Tests: `StuffCoreTests` in `StuffTestHost` (`tuist test StuffCoreTests`). + +## Key types + +- [`StuffCore`](Sources/StuffCore.swift) — version placeholder until shared + non-localization code lands here. Localization tooling lives in + [`LocalizationKit`](../LocalizationKit/). + +## Testing + +Tests live in [`Tests/`](Tests) (Swift Testing only). See +[`StuffCoreTests`](Tests/StuffCoreTests.swift). diff --git a/Shared/StuffCore/README.md b/Shared/StuffCore/README.md index 33826de..bdc99c3 100644 --- a/Shared/StuffCore/README.md +++ b/Shared/StuffCore/README.md @@ -1,8 +1,8 @@ # StuffCore -Scaffold SPM library for code shared across Stuff apps. Today it only exposes a -placeholder `StuffCore.version` constant so the module, test bundle, and docs -exist before the first real API lands. +Foundation-only SPM library for code shared across Stuff apps. It currently +hosts only a version placeholder — localization tooling lives in +[`LocalizationKit`](../LocalizationKit/). Add shared types under [`Sources/`](Sources/) and wire consumers in [`Package.swift`](../../Package.swift). Run tests with `tuist test StuffCoreTests`. diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 6a2ea90..8cdb202 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -208,6 +208,93 @@ states. any distinct edge state (e.g. missing-days, elsewhere-only) each deserve a preview when the view renders them differently. +## Localized strings (`WhereUI` + `WhereCore`) + +Framework types live in [`LocalizationKit`](../Shared/LocalizationKit/) — +`LocalizedString`, `LocalizationConfig`, the generic +[`LocalizedString.catalog(_:_:bundle:)`](../Shared/LocalizationKit/Sources/LocalizedString+Catalog.swift) +factory, and the SwiftUI helpers +([`Text(localized:)`](../Shared/LocalizationKit/Sources/Text+Localized.swift), +[`View` overloads](../Shared/LocalizationKit/Sources/View+Localized.swift)). +Each Where module keeps its own catalog and a thin `.module(_:_:)` wrapper that +passes `bundle: .module`. + +### WhereUI + +Every user-facing string in `WhereUI` is funneled through +[`LocalizedStrings`](WhereUI/Sources/Shared/LocalizedStrings.swift) — views hold +no literals. The convention (which the root `localize` script parses, so it must +stay rigid — see root +[`AGENTS.md`](../AGENTS.md#keeping-localization-in-sync)): + +- A single `enum LocalizedStrings` with **nested enums** per MARK section + (`LocalizedStrings.Tabs.primary`, `LocalizedStrings.Settings.title`, …). +- Each member is a `static let` (parameter-less) or `static func` (parameterized) + returning a [`LocalizedString`](../Shared/LocalizationKit/Sources/LocalizedString.swift) + built with the + [`.module(_:_:)`](WhereUI/Sources/Shared/LocalizedString+Module.swift) wrapper + — `.module("", "")` — which delegates to + `.catalog(..., bundle: .module)`. Both arguments must be **literals**: the key + is a `StaticString` (also what lets `String(localized:)` resolve plurals), and + the script (and Xcode extraction) read both statically, failing loudly on anything + dynamic. (`LocalizedString` is `Sendable`, so the parameter-less members are + cached `static let`s rather than recomputed `static var`s.) +- Members that **compose** another string (interpolating a nested + `.localized(config)`, e.g. `Common.regionDaysAccessibility`, + `Timeline.rowAccessibility`) use the closure overload + `.module("") { "… \(nested.localized($0)) …" }`, which threads the locale + override into the nested resolution. Members that **branch on a count** + (`Common.dayUnit`, `MissingBanner.compact`) pick between two `.module` keys. + The script reads the default literal out of the closure too — so every member + stays on `.module`. +- Swift is the source of truth for keys + English defaults; the sibling + [`Resources/Localizable.xcstrings`](WhereUI/Sources/Resources/Localizable.xcstrings) + owns plural `variations` and translations. The pre-commit hook runs + `./localize --git-add` to reconcile the catalog from this file. + +At call sites that take a `LocalizedString`, use **leading-dot syntax** rather +than spelling out `LocalizedStrings.`: +[`Text(localized: .primary.emptyDescription)`](../Shared/LocalizationKit/Sources/Text+Localized.swift) +and the `View` overloads `.navigationTitle(.settings.title)` / +`.accessibilityLabel(.timeline.rowAccessibility(…))` / +`.accessibilityHint(…)` ([`View+Localized.swift`](../Shared/LocalizationKit/Sources/View+Localized.swift)). +The dot accessors live in +[`LocalizedString+DotSyntax.swift`](WhereUI/Sources/Shared/LocalizedString+DotSyntax.swift): +each is the metatype of a `LocalizedStrings` nested enum, so the chain resolves +to the same `static let`/`static func` members (no second copy of any string). +Add a new category there (top-level on `LocalizedString`; nested ones on their +parent, e.g. `LocalizedStrings.Settings`). + +Dot syntax only fires where the contextual type is `LocalizedString`. Anywhere a +plain `String` is needed (`Button`, `Label`, `accessibilityValue`, interpolation, +…), keep `LocalizedStrings...localized`. Both paths run through +`LocalizedString.localized(_:)`, the single seam a future Environment-driven +locale override will hook into. + +### WhereCore + +Non-UI strings (region names, notification copy, backup errors) live in +[`WhereCore/Sources/LocalizedStrings.swift`](WhereCore/Sources/LocalizedStrings.swift) +with the same `.module` / `./localize` convention and a sibling +[`Resources/Localizable.xcstrings`](WhereCore/Sources/Resources/Localizable.xcstrings). +[`Region.localizedName`](WhereCore/Sources/Region.swift) resolves via +`LocalizedStrings.Region..localized` — the property stays `String` so UI +call sites are unchanged. WhereCore has no dot-syntax helpers; strings resolve +immediately at the call site. + +### LifecycleKit + +Launch failure UI copy lives in +[`Shared/LifecycleKit/Sources/LocalizedStrings.swift`](../Shared/LifecycleKit/Sources/LocalizedStrings.swift) +with the same `.module` / `./localize` convention. + +### WhereWidgets + +Widget gallery name/description strings live in +[`WhereWidgets/Sources/LocalizedStrings.swift`](WhereWidgets/Sources/LocalizedStrings.swift) +with the same `.module` / `./localize` convention. Runtime widget content stays +in WhereUI. + ## Adding things - **New library target:** add to root @@ -227,6 +314,10 @@ states. needs updating. - **New SwiftUI view / widget:** add a `#Preview` in the same file (see [SwiftUI views & previews](#swiftui-views--previews)). +- **New user-facing string:** add a member to + [`LocalizedStrings`](WhereUI/Sources/Shared/LocalizedStrings.swift) (don't + inline literals in views), then let `./localize` reconcile the catalog (see + [Localized strings](#localized-strings-whereui)). - **New app icon:** run `./icons --add <1024.png> --name ` (see the root [`AGENTS.md`](../AGENTS.md#managing-app-icons)) — it updates both asset catalogs and `AppIcons.json`. Don't hand-edit those; the picker is diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index ffc8e0d..46bbbaf 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, - ) + LocalizedStrings.Backup.manifestMissing.localized 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, - ) + LocalizedStrings.Backup.unsupportedFormatVersion(version).localized } } } diff --git a/Where/WhereCore/Sources/DailySummaryReconciler.swift b/Where/WhereCore/Sources/DailySummaryReconciler.swift index e3f4748..5c3b3a3 100644 --- a/Where/WhereCore/Sources/DailySummaryReconciler.swift +++ b/Where/WhereCore/Sources/DailySummaryReconciler.swift @@ -89,7 +89,7 @@ public actor DailySummaryReconciler { .prefix(regionLimit) guard !ranked.isEmpty else { - return String(localized: "summary.notification.body.empty", bundle: .module) + return LocalizedStrings.Summary.notificationBodyEmpty.localized } return ranked @@ -98,18 +98,9 @@ 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, + String( + format: LocalizedStrings.Summary.regionDays.localized, + LocalizedStrings.Summary.dayCount(days).localized, region.localizedName, ) } diff --git a/Where/WhereCore/Sources/LocalizedString+Module.swift b/Where/WhereCore/Sources/LocalizedString+Module.swift new file mode 100644 index 0000000..497cceb --- /dev/null +++ b/Where/WhereCore/Sources/LocalizedString+Module.swift @@ -0,0 +1,19 @@ +import LocalizationKit + +extension LocalizedString { + /// A WhereCore catalog string — delegates to ``LocalizedString/catalog(_:_:bundle:)`` + /// with `bundle: .module`. + static func module( + _ key: StaticString, + _ defaultValue: String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, defaultValue, bundle: .module) + } + + static func module( + _ key: StaticString, + _ defaultValue: @Sendable @escaping (LocalizationConfig?) -> String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, bundle: .module, defaultValue) + } +} diff --git a/Where/WhereCore/Sources/LocalizedStrings.swift b/Where/WhereCore/Sources/LocalizedStrings.swift new file mode 100644 index 0000000..9bf3722 --- /dev/null +++ b/Where/WhereCore/Sources/LocalizedStrings.swift @@ -0,0 +1,74 @@ +import LocalizationKit + +/// Catalog-backed strings for WhereCore. +/// +/// Swift is the source of truth for keys and English defaults; the sibling +/// `Resources/Localizable.xcstrings` owns plural `variations` and translations. +/// The root `./localize` script reconciles the catalog from this file. +enum LocalizedStrings { + // MARK: Regions + + enum Region { + static let california: LocalizedString = .module("region.california", "California") + static let newYork: LocalizedString = .module("region.newYork", "New York") + static let canada: LocalizedString = .module("region.canada", "Canada") + static let europeanUnion: LocalizedString = .module( + "region.europeanUnion", + "European Union", + ) + static let other: LocalizedString = .module("region.other", "Other") + } + + // MARK: Reminders + + enum Reminder { + static let notificationTitle: LocalizedString = .module( + "reminder.notification.title", + "Log today's location", + ) + + static let notificationBody: LocalizedString = .module( + "reminder.notification.body", + "Open Where before the day ends so we don't miss logging today.", + ) + } + + // MARK: Daily summary + + enum Summary { + static let notificationTitle: LocalizedString = .module( + "summary.notification.title", + "Your year so far", + ) + + static let notificationBodyEmpty: LocalizedString = .module( + "summary.notification.body.empty", + "No days logged yet this year.", + ) + + static func dayCount(_ days: Int) -> LocalizedString { + .module("summary.notification.dayCount", "\(days) days") + } + + static let regionDays: LocalizedString = .module( + "summary.notification.regionDays", + "%1$@ in %2$@", + ) + } + + // MARK: Backup + + enum Backup { + static let manifestMissing: LocalizedString = .module( + "backup.error.manifestMissing", + "This file isn't a Where backup (no manifest was found).", + ) + + static func unsupportedFormatVersion(_ version: Int) -> LocalizedString { + .module( + "backup.error.unsupportedFormatVersion", + "This backup was created by a newer version of Where (format \(version)) and can't be imported.", + ) + } + } +} diff --git a/Where/WhereCore/Sources/Region.swift b/Where/WhereCore/Sources/Region.swift index f340b4c..6c680c9 100644 --- a/Where/WhereCore/Sources/Region.swift +++ b/Where/WhereCore/Sources/Region.swift @@ -19,26 +19,25 @@ public enum Region: String, Codable, Sendable, Hashable, CaseIterable { case other /// User-facing name for this region, read from the `WhereCore` - /// string catalog (`Resources/Localizable.xcstrings`). + /// string catalog via ``LocalizedStrings/Region``. /// - /// 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. + /// Each case maps to a literal catalog key (rather than a runtime-composed + /// `"region.\(rawValue)"`) so Xcode's string-catalog extraction tooling and + /// the repo's `./localize` script can statically find every key. Adding a + /// new region case is intentionally a compile error here until you add a + /// matching ``LocalizedStrings/Region`` member and catalog entry. public var localizedName: String { switch self { case .california: - String(localized: "region.california", bundle: .module) + LocalizedStrings.Region.california.localized case .newYork: - String(localized: "region.newYork", bundle: .module) + LocalizedStrings.Region.newYork.localized case .canada: - String(localized: "region.canada", bundle: .module) + LocalizedStrings.Region.canada.localized case .europeanUnion: - String(localized: "region.europeanUnion", bundle: .module) + LocalizedStrings.Region.europeanUnion.localized case .other: - String(localized: "region.other", bundle: .module) + LocalizedStrings.Region.other.localized } } } diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift index ad1edda..3541563 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 = LocalizedStrings.Summary.notificationTitle.localized content.body = body content.sound = .default diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 0fff840..197fbe0 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -220,8 +220,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 = LocalizedStrings.Reminder.notificationTitle.localized + content.body = LocalizedStrings.Reminder.notificationBody.localized content.sound = .default let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) diff --git a/Where/WhereCore/Sources/Resources/Localizable.xcstrings b/Where/WhereCore/Sources/Resources/Localizable.xcstrings index e229cb0..24dbe0d 100644 --- a/Where/WhereCore/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereCore/Sources/Resources/Localizable.xcstrings @@ -157,5 +157,5 @@ } } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Where/WhereUI/Sources/Launch/LaunchSplashView.swift b/Where/WhereUI/Sources/Launch/LaunchSplashView.swift index 355dd8d..dd56b41 100644 --- a/Where/WhereUI/Sources/Launch/LaunchSplashView.swift +++ b/Where/WhereUI/Sources/Launch/LaunchSplashView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import UIKit @@ -61,7 +62,7 @@ struct LaunchSplashView: View { .background(Color.black) .ignoresSafeArea() .accessibilityElement(children: .ignore) - .accessibilityLabel(showCaption ? Strings.migrationTitle : Strings.launchAccessibilityLabel) + .accessibilityLabel(showCaption ? .migration.title : .launch.accessibilityLabel) .task { try? await Task.sleep(for: Self.captionDelay) guard !Task.isCancelled else { return } @@ -78,9 +79,9 @@ struct LaunchSplashView: View { /// light since the backdrop is always dark. private var caption: some View { VStack(spacing: UIConstants.Spacings.small) { - Text(Strings.migrationTitle) + Text(localized: .migration.title) .font(.headline) - Text(Strings.migrationSubtitle) + Text(localized: .migration.subtitle) .font(.subheadline) .foregroundStyle(.white.opacity(0.7)) } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 7e30088..712ae88 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -1,4 +1,5 @@ import Foundation +import LocalizationKit import LogKit import Observation #if DEBUG @@ -731,7 +732,7 @@ public final class WhereSession { return SwiftDataInspectorConfiguration( container: container, modelTypes: SwiftDataStore.inspectorModelTypes, - title: Strings.settingsDebugInspectorTitle, + title: LocalizedStrings.Settings.Debug.inspectorTitle.localized, ) } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 0bf9b2a..883849d 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -1,4 +1,5 @@ import LifecycleKit +import LocalizationKit import SwiftUI import WhereCore @@ -79,7 +80,7 @@ public struct OnboardingView: View { Button { withAnimation { page += 1 } } label: { - Text(Strings.onboardingContinue) + Text(localized: .onboarding.continueButton) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) @@ -97,13 +98,13 @@ public struct OnboardingView: View { completeAndContinue() } } label: { - Text(Strings.onboardingEnableLocation) + Text(localized: .onboarding.enableLocation) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) - Button(Strings.onboardingNotNow) { + Button(LocalizedStrings.Onboarding.notNow.localized) { guard !isFinishing else { return } isFinishing = true completeAndContinue() @@ -135,20 +136,20 @@ struct OnboardingPage: Identifiable { OnboardingPage( id: "welcome", symbol: "globe.americas.fill", - title: Strings.onboardingWelcomeTitle, - description: Strings.onboardingWelcomeDescription, + title: LocalizedStrings.Onboarding.welcomeTitle.localized, + description: LocalizedStrings.Onboarding.welcomeDescription.localized, ), OnboardingPage( id: "automatic", symbol: "location.fill.viewfinder", - title: Strings.onboardingAutomaticTitle, - description: Strings.onboardingAutomaticDescription, + title: LocalizedStrings.Onboarding.automaticTitle.localized, + description: LocalizedStrings.Onboarding.automaticDescription.localized, ), OnboardingPage( id: "privacy", symbol: "lock.shield.fill", - title: Strings.onboardingPrivacyTitle, - description: Strings.onboardingPrivacyDescription, + title: LocalizedStrings.Onboarding.privacyTitle.localized, + description: LocalizedStrings.Onboarding.privacyDescription.localized, ), ] } diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index e1841ad..2ae2fab 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -38,7 +39,7 @@ struct CalendarView: View { case let .failure(error): calendarLayoutError(error) case nil: - ProgressView(Strings.primaryLoading) + ProgressView(LocalizedStrings.Primary.loading.localized) } } .task(id: calendarLoadID(report: report)) { @@ -47,18 +48,24 @@ struct CalendarView: View { monthsLoad = result } } else if session.loadState == .loading { - ProgressView(Strings.primaryLoading) + ProgressView(LocalizedStrings.Primary.loading.localized) } else if case let .failed(message) = session.loadState { ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label( + LocalizedStrings.Common.loadErrorTitle.localized, + systemImage: "exclamationmark.icloud", + ) } description: { Text(message) } } else { ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label( + LocalizedStrings.Common.loadErrorTitle.localized, + systemImage: "exclamationmark.icloud", + ) } description: { - Text(Strings.calendarUnavailableDescription) + Text(localized: .calendar.unavailableDescription) } .onAppear { Self.logger.warning( @@ -67,16 +74,16 @@ struct CalendarView: View { } } } - .navigationTitle(Strings.calendarTitle(year: session.selectedYear)) + .navigationTitle(.calendar.title(year: session.selectedYear)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.commonDone) { dismiss() } + Button(LocalizedStrings.Common.done.localized) { dismiss() } } } .navigationDestination(item: $timelineTarget) { target in PresenceTimelineList(scrollToMonth: target.startOfMonth) - .navigationTitle(Strings.timelineTitle(year: session.selectedYear)) + .navigationTitle(.timeline.title(year: session.selectedYear)) .navigationBarTitleDisplayMode(.inline) } } @@ -102,9 +109,12 @@ struct CalendarView: View { private func calendarLayoutError(_ error: Error) -> some View { ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label( + LocalizedStrings.Common.loadErrorTitle.localized, + systemImage: "exclamationmark.icloud", + ) } description: { - Text(Strings.calendarUnavailableDescription) + Text(localized: .calendar.unavailableDescription) } .onAppear { Self.logger.warning("Calendar layout failed: \(error)") @@ -226,7 +236,7 @@ private struct DayCell: View { .contentShape(Rectangle()) .accessibilityElement(children: .ignore) .accessibilityLabel( - Strings.calendarDayAccessibility( + LocalizedStrings.Calendar.dayAccessibility( date: day.date, regions: day.regions, needsAttention: day.needsAttention, diff --git a/Where/WhereUI/Sources/Primary/MissingDaysView.swift b/Where/WhereUI/Sources/Primary/MissingDaysView.swift index d03d7a0..0984287 100644 --- a/Where/WhereUI/Sources/Primary/MissingDaysView.swift +++ b/Where/WhereUI/Sources/Primary/MissingDaysView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -12,11 +13,11 @@ struct MissingDaysView: View { var body: some View { NavigationStack { content - .navigationTitle(Strings.missingDaysTitle) + .navigationTitle(.missingDays.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.missingDaysDone) { dismiss() } + Button(LocalizedStrings.MissingDays.done.localized) { dismiss() } } } } @@ -26,9 +27,12 @@ struct MissingDaysView: View { private var content: some View { if session.missingDays.isEmpty { ContentUnavailableView { - Label(Strings.missingDaysEmptyTitle, systemImage: "checkmark.circle") + Label( + LocalizedStrings.MissingDays.emptyTitle.localized, + systemImage: "checkmark.circle", + ) } description: { - Text(Strings.missingDaysEmptyDescription) + Text(localized: .missingDays.emptyDescription) } } else { List { @@ -41,9 +45,9 @@ struct MissingDaysView: View { } } } header: { - Text(Strings.missingDaysHeader) + Text(localized: .missingDays.header) } footer: { - Text(Strings.missingDaysFooter) + Text(localized: .missingDays.footer) } } .accessibilityIdentifier("where_missing_days_list") @@ -64,7 +68,7 @@ private struct MissingDayRow: View { VStack(alignment: .leading, spacing: UIConstants.Spacings.xxSmall) { Text(dateRange) .font(.headline) - Text(Strings.dayCount(range.dayCount)) + Text(localized: .common.dayCount(range.dayCount)) .font(.subheadline) .foregroundStyle(.secondary) .monospacedDigit() diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index 10c2b29..76b0a7e 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -11,11 +12,11 @@ struct PresenceTimelineView: View { var body: some View { NavigationStack { PresenceTimelineList() - .navigationTitle(Strings.timelineTitle(year: session.selectedYear)) + .navigationTitle(.timeline.title(year: session.selectedYear)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.timelineDone) { dismiss() } + Button(LocalizedStrings.Timeline.done.localized) { dismiss() } } } } @@ -38,9 +39,12 @@ struct PresenceTimelineList: View { var body: some View { if stints.isEmpty { ContentUnavailableView { - Label(Strings.timelineEmptyTitle, systemImage: "calendar.day.timeline.left") + Label( + LocalizedStrings.Timeline.emptyTitle.localized, + systemImage: "calendar.day.timeline.left", + ) } description: { - Text(Strings.timelineEmptyDescription) + Text(localized: .timeline.emptyDescription) } } else { ScrollViewReader { proxy in @@ -56,7 +60,7 @@ struct PresenceTimelineList: View { private func scrollToTargetMonth(_ proxy: ScrollViewProxy) { guard let startOfMonth = scrollToMonth else { return } - let calendar = Calendar.current + let calendar = session.calendar guard let nextMonth = calendar.date(byAdding: .month, value: 1, to: startOfMonth) else { return } guard let target = stints.first(where: { $0.end >= startOfMonth && $0.start < nextMonth }) @@ -99,7 +103,7 @@ private struct StintRow: View { Spacer(minLength: UIConstants.Spacings.medium) - Text(Strings.dayCount(stint.dayCount)) + Text(localized: .common.dayCount(stint.dayCount)) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) .monospacedDigit() @@ -107,7 +111,7 @@ private struct StintRow: View { .padding(.vertical, UIConstants.Spacings.xSmall) .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.timelineRowAccessibility( + .timeline.rowAccessibility( 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 ab57f71..a081063 100644 --- a/Where/WhereUI/Sources/Primary/PrimaryView.swift +++ b/Where/WhereUI/Sources/Primary/PrimaryView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -17,7 +18,7 @@ struct PrimaryView: View { var body: some View { NavigationStack { VStack(spacing: 0) { - PassportMasthead(title: Strings.primaryTitle, tilt: tilt) + PassportMasthead(title: LocalizedStrings.Primary.title.localized, tilt: tilt) .padding(.horizontal) .padding(.top, UIConstants.Spacings.small) .padding(.bottom, UIConstants.Spacings.medium) @@ -41,7 +42,7 @@ struct PrimaryView: View { showingTimeline = true } label: { Label( - Strings.primaryTimeline, + LocalizedStrings.Primary.timeline.localized, systemImage: "calendar.day.timeline.left", ) } @@ -52,7 +53,7 @@ struct PrimaryView: View { showingCalendar = true } label: { Label( - Strings.primaryCalendar, + LocalizedStrings.Primary.calendar.localized, systemImage: "calendar", ) } @@ -98,11 +99,14 @@ struct PrimaryView: View { private var screen: some View { switch session.loadState { case .loading where session.report == nil: - ProgressView(Strings.primaryLoading) + ProgressView(LocalizedStrings.Primary.loading.localized) .frame(maxWidth: .infinity, maxHeight: .infinity) case let .failed(message): ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label( + LocalizedStrings.Common.loadErrorTitle.localized, + systemImage: "exclamationmark.icloud", + ) } description: { Text(message) } @@ -143,17 +147,23 @@ struct PrimaryView: View { private var emptyState: some View { ContentUnavailableView { - Label(Strings.primaryEmptyTitle(year: session.selectedYear), systemImage: "map") + Label( + LocalizedStrings.Primary.emptyTitle(year: session.selectedYear).localized, + systemImage: "map", + ) } description: { - Text(Strings.primaryEmptyDescription) + Text(localized: .primary.emptyDescription) } } private var elsewhereOnlyState: some View { ContentUnavailableView { - Label(Strings.primaryElsewhereOnlyTitle, systemImage: "globe.americas") + Label( + LocalizedStrings.Primary.elsewhereOnlyTitle.localized, + systemImage: "globe.americas", + ) } description: { - Text(Strings.primaryElsewhereOnlyDescription(count: session.trackedDayCount)) + Text(localized: .primary.elsewhereOnlyDescription(count: session.trackedDayCount)) } } } @@ -240,7 +250,7 @@ private struct MissingDaysBanner: View { .symbolEffect(.variableColor.iterative, isActive: !reduceMotion) .accessibilityHidden(true) - Text(Strings.missingBannerCompact(count: count)) + Text(localized: .missingBanner.compact(count: count)) .font(.footnote.weight(.semibold)) .foregroundStyle(.primary) @@ -265,8 +275,8 @@ private struct MissingDaysBanner: View { } .buttonStyle(.plain) .accessibilityIdentifier("where_missing_days_banner") - .accessibilityLabel(Strings.missingBannerCompact(count: count)) - .accessibilityHint(Strings.missingBannerAccessibilityHint) + .accessibilityLabel(.missingBanner.compact(count: count)) + .accessibilityHint(.missingBanner.accessibilityHint) } } diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index 2b52934..5a401b6 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -1,4 +1,5 @@ import Foundation +import LocalizationKit import SwiftUI import WhereCore @@ -199,7 +200,7 @@ struct RegionSummaryCard: View { ) .contentTransition(.numericText()) .foregroundStyle(style.tint) - Text(Strings.dayUnit(regionDays.days)) + Text(localized: .common.dayUnit(regionDays.days)) .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) } @@ -243,7 +244,7 @@ struct RegionSummaryCard: View { ) .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.regionDaysAccessibility( + .common.regionDaysAccessibility( region: regionDays.region.localizedName, days: regionDays.days, ), diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index fcb171c..78fb1db 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -3,10 +3,6 @@ "strings" : { "" : { - }, - "%lld" : { - "comment" : "A day in the calendar, showing the day number and a row of colored dots for each region present on that day.", - "isCommentAutoGenerated" : true }, "appIcon.appearance.dark" : { "extractionState" : "manual", diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index e4085e2..f554c5c 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -1,4 +1,5 @@ import LifecycleKit +import LocalizationKit import SwiftUI import WhereCore @@ -40,15 +41,18 @@ public struct RootView: View { failure: { LifecycleFailureView(failure: $0, retry: $1) }, ) { TabView { - Tab(Strings.tabPrimary, systemImage: "star.fill") { + Tab(LocalizedStrings.Tabs.primary.localized, systemImage: "star.fill") { PrimaryView() } - Tab(Strings.tabElsewhere, systemImage: "globe.americas.fill") { + Tab( + LocalizedStrings.Tabs.elsewhere.localized, + systemImage: "globe.americas.fill", + ) { SecondaryView() } - Tab(Strings.tabSettings, systemImage: "gearshape.fill") { + Tab(LocalizedStrings.Tabs.settings.localized, systemImage: "gearshape.fill") { SettingsView() } } diff --git a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift index fd458c1..8b51169 100644 --- a/Where/WhereUI/Sources/Secondary/DayRelabelView.swift +++ b/Where/WhereUI/Sources/Secondary/DayRelabelView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -31,7 +32,7 @@ struct DayRelabelView: View { Form { Section { - LabeledContent(Strings.relabelTitle, value: dateText) + LabeledContent(LocalizedStrings.Relabel.title.localized, value: dateText) } Section { @@ -39,31 +40,31 @@ struct DayRelabelView: View { RegionToggleRow(item: item) } } header: { - Text(Strings.relabelRegionsHeader) + Text(localized: .relabel.regionsHeader) } footer: { - Text(Strings.relabelRegionsFooter) + Text(localized: .relabel.regionsFooter) } Section { - Button(Strings.relabelReset, role: .destructive) { reset() } + Button(LocalizedStrings.Relabel.reset.localized, role: .destructive) { reset() } .disabled(isSaving) } footer: { - Text(Strings.relabelResetFooter) + Text(localized: .relabel.resetFooter) } } - .navigationTitle(Strings.relabelTitle) + .navigationTitle(.relabel.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.manualSave) { save() } + Button(LocalizedStrings.ManualEntry.save.localized) { save() } .disabled(!canSave) } } .alert( - Strings.manualSaveErrorTitle, + LocalizedStrings.ManualEntry.saveErrorTitle.localized, isPresented: $saveError.isPresented, ) { - Button(Strings.commonOK, role: .cancel) {} + Button(LocalizedStrings.Common.ok.localized, role: .cancel) {} } message: { if let saveError = saveError.message { Text(saveError) diff --git a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift index dfb28aa..ff1ad8e 100644 --- a/Where/WhereUI/Sources/Secondary/RegionDaysView.swift +++ b/Where/WhereUI/Sources/Secondary/RegionDaysView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import MapKit import SwiftUI import WhereCore @@ -50,9 +51,12 @@ struct RegionDaysView: View { private var content: some View { if days.isEmpty { ContentUnavailableView { - Label(Strings.secondaryRegionEmptyTitle, systemImage: "checkmark.circle") + Label( + LocalizedStrings.Secondary.Region.emptyTitle.localized, + systemImage: "checkmark.circle", + ) } description: { - Text(Strings.secondaryRegionEmptyDescription) + Text(localized: .secondary.region.emptyDescription) } } else { VStack(spacing: 0) { @@ -73,7 +77,7 @@ struct RegionDaysView: View { } .mapStyle(.standard(pointsOfInterest: .excludingAll)) .frame(height: UIConstants.Size.regionMapHeight) - .accessibilityLabel(Strings.secondaryRegionMapAccessibility) + .accessibilityLabel(.secondary.region.mapAccessibility) } private var dayList: some View { @@ -87,7 +91,7 @@ struct RegionDaysView: View { } } } footer: { - Text(Strings.secondaryRegionFooter) + Text(localized: .secondary.region.footer) } } .accessibilityIdentifier("where_region_days_list") @@ -145,7 +149,7 @@ private struct DayRow: View { .font(.subheadline) .foregroundStyle(.primary) } - Text(Strings.secondaryRegionCurrent(regions: regionsText)) + Text(localized: .secondary.region.current(regions: regionsText)) .font(.footnote) .foregroundStyle(.secondary) } diff --git a/Where/WhereUI/Sources/Secondary/SecondaryView.swift b/Where/WhereUI/Sources/Secondary/SecondaryView.swift index d08c4b3..04a6552 100644 --- a/Where/WhereUI/Sources/Secondary/SecondaryView.swift +++ b/Where/WhereUI/Sources/Secondary/SecondaryView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -14,7 +15,7 @@ struct SecondaryView: View { var body: some View { NavigationStack { screen - .navigationTitle(Strings.secondaryTitle) + .navigationTitle(.secondary.title) .toolbar { ToolbarItem(placement: .topBarTrailing) { YearSelector() @@ -45,11 +46,14 @@ struct SecondaryView: View { private var screen: some View { switch session.loadState { case .loading where session.report == nil: - ProgressView(Strings.secondaryLoading) + ProgressView(LocalizedStrings.Secondary.loading.localized) .frame(maxWidth: .infinity, maxHeight: .infinity) case let .failed(message): ContentUnavailableView { - Label(Strings.loadErrorTitle, systemImage: "exclamationmark.icloud") + Label( + LocalizedStrings.Common.loadErrorTitle.localized, + systemImage: "exclamationmark.icloud", + ) } description: { Text(message) } @@ -65,7 +69,7 @@ struct SecondaryView: View { private var content: some View { ScrollView { VStack(alignment: .leading, spacing: UIConstants.Spacings.xLarge) { - Text(Strings.secondaryHeader(year: session.selectedYear)) + Text(localized: .secondary.header(year: session.selectedYear)) .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -96,15 +100,15 @@ struct SecondaryView: View { private var emptyState: some View { ContentUnavailableView { - Label(Strings.secondaryEmptyTitle, systemImage: "globe.americas") + Label(LocalizedStrings.Secondary.emptyTitle.localized, systemImage: "globe.americas") } description: { - Text(Strings.secondaryEmptyDescription) + Text(localized: .secondary.emptyDescription) } } /// Light whimsy for the briefest stays. private func caption(for item: RegionDays) -> String? { - item.days <= 3 ? Strings.secondaryCaptionPassingThrough : nil + item.days <= 3 ? LocalizedStrings.Secondary.captionPassingThrough.localized : nil } } diff --git a/Where/WhereUI/Sources/Settings/AppIconView.swift b/Where/WhereUI/Sources/Settings/AppIconView.swift index 6d52b28..f91159c 100644 --- a/Where/WhereUI/Sources/Settings/AppIconView.swift +++ b/Where/WhereUI/Sources/Settings/AppIconView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI /// The app-icon picker. A grid of options that flexes with the container width @@ -37,11 +38,11 @@ struct AppIconView: View { .transition(.move(edge: .bottom)) } } - .navigationTitle(Strings.appIconTitle) + .navigationTitle(.appIcon.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.commonDone) { dismiss() } + Button(LocalizedStrings.Common.done.localized) { dismiss() } } } } @@ -51,8 +52,11 @@ struct AppIconView: View { } .sensoryFeedback(.selection, trigger: appearanceToggles) .sensoryFeedback(.success, trigger: model.selectedID) - .alert(Strings.appIconErrorTitle, isPresented: $model.isShowingError) { - Button(Strings.commonOK, role: .cancel) {} + .alert( + LocalizedStrings.AppIcon.errorTitle.localized, + isPresented: $model.isShowingError, + ) { + Button(LocalizedStrings.Common.ok.localized, role: .cancel) {} } message: { Text(model.applyError ?? "") } @@ -110,7 +114,7 @@ struct AppIconView: View { .buttonStyle(.plain) .accessibilityElement(children: .combine) .accessibilityLabel(option.displayName) - .accessibilityValue(isSelected ? Strings.appIconCurrent : "") + .accessibilityValue(isSelected ? LocalizedStrings.AppIcon.current.localized : "") .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } @@ -121,7 +125,7 @@ struct AppIconView: View { .transition(.opacity) .onTapGesture { dismissPreview() } .accessibilityAddTraits(.isButton) - .accessibilityLabel(Strings.commonDone) + .accessibilityLabel(.common.done) .accessibilityAction { dismissPreview() } } @@ -139,14 +143,15 @@ struct AppIconView: View { } .buttonStyle(.plain) .accessibilityLabel(option.displayName) - .accessibilityValue(previewMode == .dark ? Strings.appIconAppearanceDark : Strings - .appIconAppearanceLight) - .accessibilityHint(Strings.appIconAppearanceHint) + .accessibilityValue(previewMode == .dark + ? LocalizedStrings.AppIcon.appearanceDark.localized + : LocalizedStrings.AppIcon.appearanceLight.localized) + .accessibilityHint(.appIcon.appearanceHint) VStack(spacing: UIConstants.Spacings.xSmall) { Text(option.displayName) .font(.title3.weight(.semibold)) - Text(Strings.appIconAppearanceHint) + Text(localized: .appIcon.appearanceHint) .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -155,7 +160,7 @@ struct AppIconView: View { Button { apply(option) } label: { - Text(Strings.appIconSet) + Text(localized: .appIcon.set) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) diff --git a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift index 56eea29..0a2d6b0 100644 --- a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift +++ b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -37,7 +38,7 @@ struct LocationStatusRow: View { return Presentation( symbol: "location.fill", tint: .green, - title: Strings.settingsStatusTracking, + title: LocalizedStrings.Settings.Status.tracking.localized, ) } switch status { @@ -45,31 +46,31 @@ struct LocationStatusRow: View { return Presentation( symbol: "location.fill", tint: .green, - title: Strings.settingsStatusAlwaysPaused, + title: LocalizedStrings.Settings.Status.alwaysPaused.localized, ) case .whenInUse: return Presentation( symbol: "location", tint: .orange, - title: Strings.settingsStatusWhenInUse, + title: LocalizedStrings.Settings.Status.whenInUse.localized, ) case .notDetermined: return Presentation( symbol: "location.slash", tint: .secondary, - title: Strings.settingsStatusNotDetermined, + title: LocalizedStrings.Settings.Status.notDetermined.localized, ) case .denied: return Presentation( symbol: "location.slash.fill", tint: .red, - title: Strings.settingsStatusDenied, + title: LocalizedStrings.Settings.Status.denied.localized, ) case .restricted: return Presentation( symbol: "lock.fill", tint: .red, - title: Strings.settingsStatusRestricted, + title: LocalizedStrings.Settings.Status.restricted.localized, ) } } diff --git a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift index 73029d0..f8e9cdf 100644 --- a/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift +++ b/Where/WhereUI/Sources/Settings/ManualDayEntryView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -19,8 +20,8 @@ struct ManualDayEntryView: View { var title: String { switch self { - case .singleDay: Strings.manualModeSingleDay - case .range: Strings.manualModeRange + case .singleDay: LocalizedStrings.ManualEntry.modeSingleDay.localized + case .range: LocalizedStrings.ManualEntry.modeRange.localized } } } @@ -62,7 +63,7 @@ struct ManualDayEntryView: View { Form { Section { - Picker(Strings.manualEntryPickerLabel, selection: $mode) { + Picker(LocalizedStrings.ManualEntry.pickerLabel.localized, selection: $mode) { ForEach(EntryMode.allCases) { mode in Text(mode.title).tag(mode) } @@ -80,27 +81,27 @@ struct ManualDayEntryView: View { RegionToggleRow(item: item) } } header: { - Text(Strings.manualRegionsHeader) + Text(localized: .manualEntry.regionsHeader) } footer: { - Text(Strings.manualRegionsFooter) + Text(localized: .manualEntry.regionsFooter) } } - .navigationTitle(Strings.manualTitle) + .navigationTitle(.manualEntry.title) .navigationBarTitleDisplayMode(.inline) .onChange(of: startDate) { _, newValue in if endDate < newValue { endDate = newValue } } .toolbar { ToolbarItem(placement: .confirmationAction) { - Button(Strings.manualSave) { save() } + Button(LocalizedStrings.ManualEntry.save.localized) { save() } .disabled(!canSave) } } .alert( - Strings.manualSaveErrorTitle, + LocalizedStrings.ManualEntry.saveErrorTitle.localized, isPresented: $saveError.isPresented, ) { - Button(Strings.commonOK, role: .cancel) {} + Button(LocalizedStrings.Common.ok.localized, role: .cancel) {} } message: { if let saveError = saveError.message { Text(saveError) @@ -113,20 +114,20 @@ struct ManualDayEntryView: View { switch mode { case .singleDay: DatePicker( - Strings.manualDay, + LocalizedStrings.ManualEntry.day.localized, selection: $startDate, in: ...Date(), displayedComponents: .date, ) case .range: DatePicker( - Strings.manualFrom, + LocalizedStrings.ManualEntry.from.localized, selection: $startDate, in: ...Date(), displayedComponents: .date, ) DatePicker( - Strings.manualThrough, + LocalizedStrings.ManualEntry.through.localized, selection: $endDate, in: startDate ... Date(), displayedComponents: .date, @@ -137,9 +138,9 @@ struct ManualDayEntryView: View { private var dateFooter: String { switch mode { case .singleDay: - Strings.manualSingleDayFooter + LocalizedStrings.ManualEntry.singleDayFooter.localized case .range: - Strings.manualRangeFooter(count: dayCount) + LocalizedStrings.ManualEntry.rangeFooter(count: dayCount).localized } } diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 10d9e2e..bcbb93d 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -1,4 +1,5 @@ import LifecycleKit +import LocalizationKit import LogViewerUI #if DEBUG import SwiftDataInspector @@ -48,15 +49,23 @@ struct SettingsView: View { developerSection #endif } - .navigationTitle(Strings.settingsTitle) + .navigationTitle(.settings.title) .sheet(isPresented: $showAppIcon) { AppIconView() } - .alert(Strings.settingsPermissionAlertTitle, isPresented: $session.permissionDenied) { - Button(Strings.settingsPermissionAlertOpenSettings) { openSystemSettings() } - Button(Strings.settingsPermissionAlertNotNow, role: .cancel) {} + .alert( + LocalizedStrings.Settings.PermissionAlert.title.localized, + isPresented: $session.permissionDenied, + ) { + Button(LocalizedStrings.Settings.PermissionAlert.openSettings.localized) { + openSystemSettings() + } + Button( + LocalizedStrings.Settings.PermissionAlert.notNow.localized, + role: .cancel, + ) {} } message: { - Text(Strings.settingsPermissionAlertMessage) + Text(localized: .settings.permissionAlert.message) } .fileImporter( isPresented: $showImporter, @@ -64,38 +73,43 @@ struct SettingsView: View { onCompletion: handleImportSelection, ) .confirmationDialog( - Strings.settingsBackupImportStrategyTitle, + LocalizedStrings.Settings.Backup.importStrategyTitle.localized, isPresented: $showStrategyDialog, titleVisibility: .visible, presenting: pendingImportURL, ) { url in - Button(Strings.settingsBackupMerge) { runImport(url: url, strategy: .merge) } - Button(Strings.settingsBackupReplace, role: .destructive) { + Button(LocalizedStrings.Settings.Backup.merge.localized) { runImport( + url: url, + strategy: .merge, + ) } + Button(LocalizedStrings.Settings.Backup.replace.localized, role: .destructive) { runImport(url: url, strategy: .replace) } - Button(Strings.settingsDataCancel, role: .cancel) { pendingImportURL = nil } + Button(LocalizedStrings.Settings.Data.cancel.localized, role: .cancel) { + pendingImportURL = nil + } } message: { _ in - Text(Strings.settingsBackupImportStrategyMessage) + Text(localized: .settings.backup.importStrategyMessage) } .alert( - Strings.settingsBackupImportedTitle, + LocalizedStrings.Settings.Backup.importedTitle.localized, isPresented: $showImportSuccess, presenting: lastImportSummary, ) { _ in - Button(Strings.commonOK, role: .cancel) {} + Button(LocalizedStrings.Common.ok.localized, role: .cancel) {} } message: { summary in - Text(Strings.settingsBackupImportedMessage( + Text(localized: .settings.backup.importedMessage( samples: summary.sampleCount, evidence: summary.evidenceCount, manualDays: summary.manualDayCount, )) } .alert( - Strings.settingsBackupErrorTitle, + LocalizedStrings.Settings.Backup.errorTitle.localized, isPresented: $session.isShowingBackupError, presenting: session.backupError, ) { _ in - Button(Strings.commonOK, role: .cancel) {} + Button(LocalizedStrings.Common.ok.localized, role: .cancel) {} } message: { message in Text(message) } @@ -108,14 +122,20 @@ struct SettingsView: View { LocationStatusRow(status: session.authorizationStatus, isTracking: session.isTracking) Toggle(isOn: $session.trackingEnabled) { - Label(Strings.settingsLocationToggle, systemImage: "location.fill") + Label( + LocalizedStrings.Settings.Location.toggle.localized, + systemImage: "location.fill", + ) } if showGrantButton { Button { Task { await session.requestPermission() } } label: { - Label(Strings.settingsLocationGrant, systemImage: "location.magnifyingglass") + Label( + LocalizedStrings.Settings.Location.grant.localized, + systemImage: "location.magnifyingglass", + ) } } @@ -123,13 +143,16 @@ struct SettingsView: View { Button { openSystemSettings() } label: { - Label(Strings.settingsPermissionAlertOpenSettings, systemImage: "gear") + Label( + LocalizedStrings.Settings.PermissionAlert.openSettings.localized, + systemImage: "gear", + ) } } } header: { - Text(Strings.settingsLocationHeader) + Text(localized: .settings.location.header) } footer: { - Text(Strings.settingsLocationFooter) + Text(localized: .settings.location.footer) } } @@ -154,12 +177,15 @@ struct SettingsView: View { @Bindable var session = session return Section { Toggle(isOn: $session.remindersEnabled) { - Label(Strings.settingsRemindersToggle, systemImage: "bell.badge") + Label( + LocalizedStrings.Settings.Reminders.toggle.localized, + systemImage: "bell.badge", + ) } if session.remindersEnabled { DatePicker( - Strings.settingsReminderTime, + LocalizedStrings.Settings.Reminders.time.localized, selection: $session.reminderTimeOfDay, displayedComponents: .hourAndMinute, ) @@ -168,12 +194,15 @@ struct SettingsView: View { Button { openSystemSettings() } label: { - Label(Strings.settingsRemindersOpenSettings, systemImage: "bell.slash") + Label( + LocalizedStrings.Settings.Reminders.openSettings.localized, + systemImage: "bell.slash", + ) } } } } header: { - Text(Strings.settingsRemindersHeader) + Text(localized: .settings.reminders.header) } footer: { Text(remindersFooter) } @@ -181,21 +210,24 @@ struct SettingsView: View { private var remindersFooter: String { if session.remindersEnabled, !session.notificationsAuthorized { - return Strings.settingsRemindersDeniedFooter + return LocalizedStrings.Settings.Reminders.deniedFooter.localized } - return Strings.settingsRemindersFooter + return LocalizedStrings.Settings.Reminders.footer.localized } private var summarySection: some View { @Bindable var session = session return Section { Toggle(isOn: $session.summaryEnabled) { - Label(Strings.settingsSummaryToggle, systemImage: "chart.bar.doc.horizontal") + Label( + LocalizedStrings.Settings.Summary.toggle.localized, + systemImage: "chart.bar.doc.horizontal", + ) } if session.summaryEnabled { DatePicker( - Strings.settingsSummaryTime, + LocalizedStrings.Settings.Summary.time.localized, selection: $session.summaryTimeOfDay, displayedComponents: .hourAndMinute, ) @@ -204,12 +236,15 @@ struct SettingsView: View { Button { openSystemSettings() } label: { - Label(Strings.settingsRemindersOpenSettings, systemImage: "bell.slash") + Label( + LocalizedStrings.Settings.Reminders.openSettings.localized, + systemImage: "bell.slash", + ) } } } } header: { - Text(Strings.settingsSummaryHeader) + Text(localized: .settings.summary.header) } footer: { Text(summaryFooter) } @@ -217,9 +252,9 @@ struct SettingsView: View { private var summaryFooter: String { if session.summaryEnabled, !session.notificationsAuthorized { - return Strings.settingsSummaryDeniedFooter + return LocalizedStrings.Settings.Summary.deniedFooter.localized } - return Strings.settingsSummaryFooter + return LocalizedStrings.Settings.Summary.footer.localized } private var appIconSection: some View { @@ -227,12 +262,12 @@ struct SettingsView: View { Button { showAppIcon = true } label: { - Label(Strings.settingsAppIconLink, systemImage: "app.badge") + Label(LocalizedStrings.Settings.AppIcon.link.localized, systemImage: "app.badge") } } header: { - Text(Strings.settingsAppIconHeader) + Text(localized: .settings.appIcon.header) } footer: { - Text(Strings.settingsAppIconFooter) + Text(localized: .settings.appIcon.footer) } } @@ -241,12 +276,15 @@ struct SettingsView: View { NavigationLink { ManualDayEntryView() } label: { - Label(Strings.settingsManualLink, systemImage: "calendar.badge.plus") + Label( + LocalizedStrings.Settings.Manual.link.localized, + systemImage: "calendar.badge.plus", + ) } } header: { - Text(Strings.settingsManualHeader) + Text(localized: .settings.manual.header) } footer: { - Text(Strings.settingsManualFooter) + Text(localized: .settings.manual.footer) } } @@ -257,9 +295,12 @@ struct SettingsView: View { // progress), so no custom `UIActivityViewController` is needed. ShareLink( item: backupArchiveFile, - preview: SharePreview(Strings.settingsBackupShareTitle), + preview: SharePreview(LocalizedStrings.Settings.Backup.shareTitle.localized), ) { - Label(Strings.settingsBackupExport, systemImage: "square.and.arrow.up") + Label( + LocalizedStrings.Settings.Backup.export.localized, + systemImage: "square.and.arrow.up", + ) } .disabled(session.backupState != .idle) @@ -269,14 +310,17 @@ struct SettingsView: View { if session.backupState == .importing { importProgressLabel } else { - Label(Strings.settingsBackupImport, systemImage: "square.and.arrow.down") + Label( + LocalizedStrings.Settings.Backup.importData.localized, + systemImage: "square.and.arrow.down", + ) } } .disabled(session.backupState != .idle) } header: { - Text(Strings.settingsBackupHeader) + Text(localized: .settings.backup.header) } footer: { - Text(Strings.settingsBackupFooter) + Text(localized: .settings.backup.footer) } } @@ -284,7 +328,10 @@ struct SettingsView: View { /// `session.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( + LocalizedStrings.Settings.Backup.importing.localized, + systemImage: "square.and.arrow.down", + ) ProgressView(value: session.backupProgress) } } @@ -336,19 +383,19 @@ struct SettingsView: View { Button(eraseTitle, role: .destructive) { Task { await session.clearSelectedYear() } } - Button(Strings.settingsDataCancel, role: .cancel) {} + Button(LocalizedStrings.Settings.Data.cancel.localized, role: .cancel) {} } message: { - Text(Strings.settingsDataConfirmMessage(year: session.selectedYear)) + Text(localized: .settings.data.confirmMessage(year: session.selectedYear)) } } header: { - Text(Strings.settingsDataHeader) + Text(localized: .settings.data.header) } footer: { - Text(Strings.settingsDataFooter(year: session.selectedYear)) + Text(localized: .settings.data.footer(year: session.selectedYear)) } } private var eraseTitle: String { - Strings.settingsDataErase(year: session.selectedYear) + LocalizedStrings.Settings.Data.erase(year: session.selectedYear).localized } /// Whole-app teardown: wipes every year's data and returns to first-run @@ -360,22 +407,25 @@ struct SettingsView: View { Button(role: .destructive) { showResetConfirmation = true } label: { - Label(Strings.settingsResetErase, systemImage: "arrow.counterclockwise") + Label( + LocalizedStrings.Settings.Reset.erase.localized, + systemImage: "arrow.counterclockwise", + ) } .confirmationDialog( - Strings.settingsResetErase, + LocalizedStrings.Settings.Reset.erase.localized, isPresented: $showResetConfirmation, titleVisibility: .visible, ) { - Button(Strings.settingsResetConfirm, role: .destructive) { + Button(LocalizedStrings.Settings.Reset.confirm.localized, role: .destructive) { requestReset() } - Button(Strings.settingsDataCancel, role: .cancel) {} + Button(LocalizedStrings.Settings.Data.cancel.localized, role: .cancel) {} } message: { - Text(Strings.settingsResetMessage) + Text(localized: .settings.reset.message) } } footer: { - Text(Strings.settingsResetFooter) + Text(localized: .settings.reset.footer) } } @@ -393,23 +443,29 @@ struct SettingsView: View { NavigationLink { LogViewer(configuration: LogViewerConfiguration( store: WhereLog.store, - title: Strings.settingsDebugLogsTitle, + title: LocalizedStrings.Settings.Debug.logsTitle.localized, )) } label: { - Label(Strings.settingsDebugLogsLink, systemImage: "ladybug") + Label( + LocalizedStrings.Settings.Debug.logsLink.localized, + systemImage: "ladybug", + ) } if let configuration = session.swiftDataInspectorConfiguration { NavigationLink { SwiftDataInspectorView(configuration: configuration) } label: { - Label(Strings.settingsDebugInspectorLink, systemImage: "cylinder.split.1x2") + Label( + LocalizedStrings.Settings.Debug.inspectorLink.localized, + systemImage: "cylinder.split.1x2", + ) } } } header: { - Text(Strings.settingsDebugHeader) + Text(localized: .settings.debug.header) } footer: { - Text(Strings.settingsDebugFooter) + Text(localized: .settings.debug.footer) } } #endif diff --git a/Where/WhereUI/Sources/Shared/LocalizedString+DotSyntax.swift b/Where/WhereUI/Sources/Shared/LocalizedString+DotSyntax.swift new file mode 100644 index 0000000..c6f8f6e --- /dev/null +++ b/Where/WhereUI/Sources/Shared/LocalizedString+DotSyntax.swift @@ -0,0 +1,134 @@ +import LocalizationKit + +/// Leading-dot access to ``LocalizedStrings`` wherever a `LocalizedString` is +/// expected (e.g. `Text(localized: .primary.emptyDescription)`, +/// `.navigationTitle(.settings.title)`). +/// +/// Each accessor returns the metatype of the matching `LocalizedStrings` nested +/// enum, so the existing `static let` / `static func` members chain straight off +/// it — there's no second copy of any string, and the `./localize` script still +/// reads the one set of `.module(...)` literals in `LocalizedStrings.swift`. +/// +/// This only fires where the contextual type is `LocalizedString`. Plain `String` +/// sites (`Button`, `Label`, interpolation, …) keep using +/// `LocalizedStrings...localized`. +extension LocalizedString { + static var tabs: LocalizedStrings.Tabs.Type { + LocalizedStrings.Tabs.self + } + + static var common: LocalizedStrings.Common.Type { + LocalizedStrings.Common.self + } + + static var primary: LocalizedStrings.Primary.Type { + LocalizedStrings.Primary.self + } + + static var secondary: LocalizedStrings.Secondary.Type { + LocalizedStrings.Secondary.self + } + + static var relabel: LocalizedStrings.Relabel.Type { + LocalizedStrings.Relabel.self + } + + static var onboarding: LocalizedStrings.Onboarding.Type { + LocalizedStrings.Onboarding.self + } + + static var migration: LocalizedStrings.Migration.Type { + LocalizedStrings.Migration.self + } + + static var launch: LocalizedStrings.Launch.Type { + LocalizedStrings.Launch.self + } + + static var settings: LocalizedStrings.Settings.Type { + LocalizedStrings.Settings.self + } + + static var appIcon: LocalizedStrings.AppIcon.Type { + LocalizedStrings.AppIcon.self + } + + static var manualEntry: LocalizedStrings.ManualEntry.Type { + LocalizedStrings.ManualEntry.self + } + + static var calendar: LocalizedStrings.Calendar.Type { + LocalizedStrings.Calendar.self + } + + static var timeline: LocalizedStrings.Timeline.Type { + LocalizedStrings.Timeline.self + } + + static var missingDays: LocalizedStrings.MissingDays.Type { + LocalizedStrings.MissingDays.self + } + + static var missingBanner: LocalizedStrings.MissingBanner + .Type + { + LocalizedStrings.MissingBanner.self + } + + static var widget: LocalizedStrings.Widget.Type { + LocalizedStrings.Widget.self + } +} + +/// Nested categories: reached as `.secondary.region.…` / `.settings.location.…`. +extension LocalizedStrings.Secondary { + static var region: Region.Type { + Region.self + } +} + +extension LocalizedStrings.Settings { + static var permissionAlert: PermissionAlert.Type { + PermissionAlert.self + } + + static var location: Location.Type { + Location.self + } + + static var status: Status.Type { + Status.self + } + + static var manual: Manual.Type { + Manual.self + } + + static var appIcon: AppIcon.Type { + AppIcon.self + } + + static var debug: Debug.Type { + Debug.self + } + + static var reminders: Reminders.Type { + Reminders.self + } + + static var summary: Summary.Type { + Summary.self + } + + static var data: Data.Type { + Data.self + } + + static var reset: Reset.Type { + Reset.self + } + + static var backup: Backup.Type { + Backup.self + } +} diff --git a/Where/WhereUI/Sources/Shared/LocalizedString+Module.swift b/Where/WhereUI/Sources/Shared/LocalizedString+Module.swift new file mode 100644 index 0000000..d325e28 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/LocalizedString+Module.swift @@ -0,0 +1,34 @@ +import LocalizationKit + +extension LocalizedString { + /// A WhereUI catalog string: looks `key` up in WhereUI's bundle, falling back + /// to `defaultValue`, and honors an optional locale override at resolution + /// time. + /// + /// This is the building block for ``LocalizedStrings`` — it bakes in + /// `bundle: .module` via ``LocalizedString/catalog(_:_:bundle:)`` so each + /// member is just a `key` plus its English default. The key stays a + /// `StaticString` literal on purpose: that's the overload of + /// `String(localized:)` that resolves plural `variations`, and it keeps both + /// Xcode's catalog extraction and the repo's `./localize` script able to read + /// every key statically. + /// + /// Members that branch on a count pick between two `.module` keys; members + /// that compose another string use the closure overload below. + static func module( + _ key: StaticString, + _ defaultValue: String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, defaultValue, bundle: .module) + } + + /// Same as ``module(_:_:)``, but the default value is built from the + /// resolution config so a composed string can thread the locale override + /// into a nested `.localized($0)`. + static func module( + _ key: StaticString, + _ defaultValue: @Sendable @escaping (LocalizationConfig?) -> String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, bundle: .module, defaultValue) + } +} diff --git a/Where/WhereUI/Sources/Shared/LocalizedStrings.swift b/Where/WhereUI/Sources/Shared/LocalizedStrings.swift new file mode 100644 index 0000000..ec57280 --- /dev/null +++ b/Where/WhereUI/Sources/Shared/LocalizedStrings.swift @@ -0,0 +1,702 @@ +import Foundation +import LocalizationKit +import WhereCore + +/// Catalog-backed, deferred strings for WhereUI. +/// +/// Every user-facing string in the module is funneled through here so views +/// stay free of literals. Members return a ``LocalizedString`` (a deferred +/// builder) rather than a resolved `String`, so the catalog lookup happens at +/// the point of display — call `.localized` (or `Text(localized:)`) there. +/// +/// Simple members use the ``LocalizedString/module(_:_:)`` factory, which is a +/// `key` plus its English default (`.module` and the locale are baked in). The +/// key is a `StaticString` literal so both Xcode's catalog extraction and the +/// repo's `./localize` script can read it statically. Members that compose +/// another string use the closure overload `.module(_:_:)`, threading the locale +/// override into the nested `.localized($0)`; members that branch on a count +/// pick between two `.module` keys. Counts use the catalog's plural variations; +/// years format without a grouping separator so they read "2026", never "2,026". +enum LocalizedStrings { + // MARK: Tabs + + enum Tabs { + static let primary: LocalizedString = .module("tab.primary", "Primary") + + static let elsewhere: LocalizedString = .module("tab.elsewhere", "Elsewhere") + + static let settings: LocalizedString = .module("tab.settings", "Settings") + } + + // MARK: Common + + enum Common { + static let loadErrorTitle: LocalizedString = .module( + "common.loadError.title", + "Couldn't load your year", + ) + + static let ok: LocalizedString = .module("common.ok", "OK") + + static let done: LocalizedString = .module("common.done", "Done") + + /// "1 day" / "5 days" — with the count rendered. + static func dayCount(_ count: Int) -> LocalizedString { + .module("common.dayCount", "\(count) days") + } + + /// "day" / "days" — the bare unit, when the count is shown separately. + static func dayUnit(_ count: Int) -> LocalizedString { + count == 1 ? .module("common.day", "day") : .module("common.days", "days") + } + + static func regionDaysAccessibility(region: String, days: Int) -> LocalizedString { + .module("common.regionDays.accessibility") { + "\(region): \(dayCount(days).localized($0))" + } + } + } + + // MARK: Primary + + enum Primary { + /// The Primary tab's masthead wordmark. + static let title: LocalizedString = .module("primary.title", "Where") + + static let timeline: LocalizedString = .module("primary.timeline", "Timeline") + + static let calendar: LocalizedString = .module("primary.calendar", "Calendar") + + static let loading: LocalizedString = .module("primary.loading", "Charting your year…") + + static let emptyDescription: LocalizedString = .module( + "primary.empty.description", + "Turn on tracking or add a day in Settings and your top spots will land here.", + ) + + static let elsewhereOnlyTitle: LocalizedString = .module( + "primary.elsewhereOnly.title", + "Nothing in your headline spots", + ) + + /// Shown when there's tracked data, but none of it lands in a primary + /// region — points the user at the Elsewhere tab. + static func elsewhereOnlyDescription(count: Int) -> LocalizedString { + .module( + "primary.elsewhereOnly.description", + "\(count) days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + ) + } + + static func emptyTitle(year: Int) -> LocalizedString { + .module("primary.empty.title", "No travels logged for \(yearText(year))") + } + } + + // MARK: Elsewhere + + enum Secondary { + static let title: LocalizedString = .module("secondary.title", "Elsewhere") + + static let loading: LocalizedString = .module("secondary.loading", "Retracing your steps…") + + static let emptyTitle: LocalizedString = .module( + "secondary.empty.title", + "Nowhere else logged", + ) + + static let emptyDescription: LocalizedString = .module( + "secondary.empty.description", + "Spend a day outside your top spots — or log a trip in Settings — and it'll appear here.", + ) + + static let captionPassingThrough: LocalizedString = .module( + "secondary.caption.passingThrough", + "Just passing through", + ) + + static func header(year: Int) -> LocalizedString { + .module("secondary.header", "Everywhere else you turned up in \(yearText(year)).") + } + + // MARK: Elsewhere region detail + + enum Region { + static let footer: LocalizedString = .module( + "secondary.region.footer", + "Tap a day to fix where it counted. Your GPS data stays untouched.", + ) + + static let emptyTitle: LocalizedString = .module( + "secondary.region.empty.title", + "Nothing to fix", + ) + + static let emptyDescription: LocalizedString = .module( + "secondary.region.empty.description", + "No days counted for this region.", + ) + + /// Caption on a day row showing the regions it currently counts for, + /// e.g. "Counts as California, New York". + static func current(regions: String) -> LocalizedString { + .module("secondary.region.current", "Counts as \(regions)") + } + + /// Accessibility label for the map of recorded points on the region + /// drill-in. + static let mapAccessibility: LocalizedString = .module( + "secondary.region.map.accessibility", + "Map of where you were", + ) + } + } + + // MARK: Relabel + + enum Relabel { + static let title: LocalizedString = .module("relabel.title", "Fix this day") + + static let regionsHeader: LocalizedString = .module( + "relabel.regions.header", + "Where were you?", + ) + + static let regionsFooter: LocalizedString = .module( + "relabel.regions.footer", + "This replaces what was recorded for this day, overriding GPS. Your raw location data is kept, so you can change it back.", + ) + + static let reset: LocalizedString = .module( + "relabel.reset", + "Reset to GPS-detected location", + ) + + static let resetFooter: LocalizedString = .module( + "relabel.reset.footer", + "Removes any manual correction for this day and restores the regions detected from GPS. Your raw location data is untouched.", + ) + } + + // MARK: Onboarding + + enum Onboarding { + static let welcomeTitle: LocalizedString = .module( + "onboarding.welcome.title", + "Where have you been?", + ) + + static let welcomeDescription: LocalizedString = .module( + "onboarding.welcome.description", + "Where keeps a private passport of which regions you spend your days in — built for residency and day-count questions.", + ) + + static let automaticTitle: LocalizedString = .module( + "onboarding.automatic.title", + "It logs itself", + ) + + static let automaticDescription: LocalizedString = .module( + "onboarding.automatic.description", + "With background location, Where quietly notes the regions you pass through. You can always add or correct days by hand.", + ) + + static let privacyTitle: LocalizedString = .module( + "onboarding.privacy.title", + "Private by design", + ) + + static let privacyDescription: LocalizedString = .module( + "onboarding.privacy.description", + "Your location stays on your device and in your own iCloud. Turn on background location to start your passport.", + ) + + static let continueButton: LocalizedString = .module("onboarding.continue", "Continue") + + static let enableLocation: LocalizedString = .module( + "onboarding.enableLocation", + "Enable Location", + ) + + static let notNow: LocalizedString = .module("onboarding.notNow", "Not Now") + } + + // MARK: Migration + + enum Migration { + static let title: LocalizedString = .module("migration.title", "Updating your data…") + + static let subtitle: LocalizedString = .module( + "migration.subtitle", + "This only takes a moment.", + ) + } + + // MARK: Launch + + enum Launch { + /// Spoken by VoiceOver while the launch splash is on screen (the icon and + /// radar animation are decorative and hidden from accessibility). + static let accessibilityLabel: LocalizedString = .module( + "launch.accessibilityLabel", + "Loading", + ) + } + + // MARK: Settings + + enum Settings { + static let title: LocalizedString = .module("settings.title", "Settings") + + enum PermissionAlert { + static let title: LocalizedString = .module( + "settings.permissionAlert.title", + "Location access needed", + ) + + static let message: LocalizedString = .module( + "settings.permissionAlert.message", + "Where needs Always location access to log which region you're in. You can grant it in the Settings app.", + ) + + static let openSettings: LocalizedString = .module( + "settings.permissionAlert.openSettings", + "Open Settings", + ) + + static let notNow: LocalizedString = .module( + "settings.permissionAlert.notNow", + "Not now", + ) + } + + enum Location { + static let header: LocalizedString = .module("settings.location.header", "Location") + + static let toggle: LocalizedString = .module( + "settings.location.toggle", + "Track in the background", + ) + + static let grant: LocalizedString = .module( + "settings.location.grant", + "Grant location access", + ) + + static let footer: LocalizedString = .module( + "settings.location.footer", + "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience.", + ) + } + + enum Status { + static let tracking: LocalizedString = .module( + "settings.status.tracking", + "Tracking in the background", + ) + + static let alwaysPaused: LocalizedString = .module( + "settings.status.alwaysPaused", + "Always allowed (paused)", + ) + + static let whenInUse: LocalizedString = .module( + "settings.status.whenInUse", + "While Using only — needs Always", + ) + + static let notDetermined: LocalizedString = .module( + "settings.status.notDetermined", + "Location access not set up", + ) + + static let denied: LocalizedString = .module( + "settings.status.denied", + "Location access denied", + ) + + static let restricted: LocalizedString = .module( + "settings.status.restricted", + "Location access restricted", + ) + } + + enum Manual { + static let header: LocalizedString = .module("settings.manual.header", "Manual entry") + + static let link: LocalizedString = .module( + "settings.manual.link", + "Log or override a day", + ) + + static let footer: LocalizedString = .module( + "settings.manual.footer", + "Backfill a trip the GPS missed, or correct a day by hand.", + ) + } + + enum AppIcon { + static let header: LocalizedString = .module("settings.appIcon.header", "Appearance") + + static let link: LocalizedString = .module("settings.appIcon.link", "App icon") + + static let footer: LocalizedString = .module( + "settings.appIcon.footer", + "Pick the icon Where shows on your Home Screen.", + ) + } + + enum Debug { + static let header: LocalizedString = .module("settings.debug.header", "Developer") + + static let logsLink: LocalizedString = .module("settings.debug.logsLink", "Logs") + + static let footer: LocalizedString = .module( + "settings.debug.footer", + "On-device logs and data tools. Debug builds only.", + ) + + static let logsTitle: LocalizedString = .module("settings.debug.logsTitle", "Logs") + + static let inspectorLink: LocalizedString = .module( + "settings.debug.inspectorLink", + "SwiftData Inspector", + ) + + static let inspectorTitle: LocalizedString = .module( + "settings.debug.inspectorTitle", + "SwiftData", + ) + } + + enum Reminders { + static let header: LocalizedString = .module("settings.reminders.header", "Reminders") + + static let toggle: LocalizedString = .module( + "settings.reminders.toggle", + "Daily logging reminder", + ) + + static let time: LocalizedString = .module("settings.reminders.time", "Remind me at") + + static let footer: LocalizedString = .module( + "settings.reminders.footer", + "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.", + ) + + static let openSettings: LocalizedString = .module( + "settings.reminders.openSettings", + "Allow notifications", + ) + + static let deniedFooter: LocalizedString = .module( + "settings.reminders.deniedFooter", + "Notifications are turned off for Where, so reminders and the badge can't appear. Turn them on in Settings.", + ) + } + + enum Summary { + static let header: LocalizedString = .module("settings.summary.header", "Daily summary") + + static let toggle: LocalizedString = .module("settings.summary.toggle", "Daily summary") + + static let time: LocalizedString = .module("settings.summary.time", "Send at") + + static let footer: LocalizedString = .module( + "settings.summary.footer", + "Get a morning recap of how many days you've logged in each region so far this year.", + ) + + static let deniedFooter: LocalizedString = .module( + "settings.summary.deniedFooter", + "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings.", + ) + } + + enum Data { + static let header: LocalizedString = .module("settings.data.header", "Data") + + static let cancel: LocalizedString = .module("settings.data.cancel", "Cancel") + + static func erase(year: Int) -> LocalizedString { + .module("settings.data.erase", "Erase \(yearText(year)) data") + } + + static func confirmMessage(year: Int) -> LocalizedString { + .module( + "settings.data.confirmMessage", + "This removes every sample, manual day, and piece of evidence in \(yearText(year)). It can't be undone.", + ) + } + + static func footer(year: Int) -> LocalizedString { + .module( + "settings.data.footer", + "Acts on the year selected on the Primary tab (\(yearText(year))).", + ) + } + } + + enum Reset { + static let erase: LocalizedString = .module( + "settings.reset.erase", + "Erase all data & reset", + ) + + static let confirm: LocalizedString = .module( + "settings.reset.confirm", + "Erase Everything & Reset", + ) + + static let message: LocalizedString = .module( + "settings.reset.message", + "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.", + ) + + static let footer: LocalizedString = .module( + "settings.reset.footer", + "Starts over from scratch, as if you'd just installed Where.", + ) + } + + enum Backup { + static let header: LocalizedString = .module("settings.backup.header", "Backup") + + static let footer: LocalizedString = .module( + "settings.backup.footer", + "Export your whole history as a .zip you can email or save to Files, then import it on another device to restore everything.", + ) + + static let export: LocalizedString = .module("settings.backup.export", "Export data") + + /// Title shown in the system share sheet preview for an exported backup. + static let shareTitle: LocalizedString = .module( + "settings.backup.shareTitle", + "Where Backup", + ) + + static let importData: LocalizedString = .module( + "settings.backup.import", + "Import data", + ) + + static let importing: LocalizedString = .module( + "settings.backup.importing", + "Importing…", + ) + + static let errorTitle: LocalizedString = .module( + "settings.backup.errorTitle", + "Backup failed", + ) + + static let importStrategyTitle: LocalizedString = .module( + "settings.backup.importStrategy.title", + "Import backup", + ) + + static let importStrategyMessage: LocalizedString = .module( + "settings.backup.importStrategy.message", + "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.", + ) + + static let merge: LocalizedString = .module("settings.backup.merge", "Merge") + + static let replace: LocalizedString = .module("settings.backup.replace", "Replace all") + + static let importedTitle: LocalizedString = .module( + "settings.backup.imported.title", + "Backup imported", + ) + + static func importedMessage( + samples: Int, + evidence: Int, + manualDays: Int, + ) -> LocalizedString { + .module( + "settings.backup.imported.message", + "Imported \(samples) location samples, \(evidence) pieces of evidence, and \(manualDays) manual days.", + ) + } + } + } + + // MARK: App icon picker + + enum AppIcon { + static let title: LocalizedString = .module("appIcon.title", "App Icon") + + static let current: LocalizedString = .module("appIcon.current", "Current") + + static let set: LocalizedString = .module("appIcon.set", "Set as App Icon") + + static let appearanceLight: LocalizedString = .module("appIcon.appearance.light", "Light") + + static let appearanceDark: LocalizedString = .module("appIcon.appearance.dark", "Dark") + + static let appearanceHint: LocalizedString = .module( + "appIcon.appearance.hint", + "Tap the icon to preview light and dark.", + ) + + static let errorTitle: LocalizedString = .module( + "appIcon.error.title", + "Couldn't Change Icon", + ) + } + + // MARK: Manual entry + + enum ManualEntry { + static let pickerLabel: LocalizedString = .module("manual.entry.pickerLabel", "Entry") + + static let modeSingleDay: LocalizedString = .module("manual.mode.singleDay", "Single day") + + static let modeRange: LocalizedString = .module("manual.mode.range", "Date range") + + static let day: LocalizedString = .module("manual.day", "Day") + + static let from: LocalizedString = .module("manual.from", "From") + + static let through: LocalizedString = .module("manual.through", "Through") + + static let singleDayFooter: LocalizedString = .module( + "manual.singleDay.footer", + "Time travel: tell Where where you really were.", + ) + + static let regionsHeader: LocalizedString = .module("manual.regions.header", "Regions") + + static let regionsFooter: LocalizedString = .module( + "manual.regions.footer", + "Saving replaces any manual regions you previously set for those days.", + ) + + static let title: LocalizedString = .module("manual.title", "Log a Day") + + static let save: LocalizedString = .module("manual.save", "Save") + + static let saveErrorTitle: LocalizedString = .module( + "manual.saveError.title", + "Couldn't save that day", + ) + + static func rangeFooter(count: Int) -> LocalizedString { + .module("manual.range.footer", "Backfilling \(count) days.") + } + } + + // MARK: Calendar + + enum Calendar { + static let unavailableDescription: LocalizedString = .module( + "calendar.unavailable.description", + "Your year data isn't available right now.", + ) + + static func title(year: Int) -> LocalizedString { + .module("calendar.title", "Calendar · \(yearText(year))") + } + + static func dayAccessibility( + date: Date, + regions: [Region], + needsAttention: Bool, + ) -> LocalizedString { + let day = date.formatted(.dateTime.weekday(.wide).month(.wide).day()) + if needsAttention { + return .module( + "calendar.day.needsAttention.accessibility", + "\(day), needs a location", + ) + } + if regions.isEmpty { + return .module("calendar.day.empty.accessibility", "\(day), nothing logged") + } + let names = regions.map(\.localizedName).joined(separator: ", ") + return .module("calendar.day.accessibility", "\(day), \(names)") + } + } + + // MARK: Timeline + + enum Timeline { + static let done: LocalizedString = .module("timeline.done", "Done") + + static let emptyTitle: LocalizedString = .module("timeline.empty.title", "No stays yet") + + static let emptyDescription: LocalizedString = .module( + "timeline.empty.description", + "Once Where has a run of days in a region, your stays will appear here.", + ) + + static func title(year: Int) -> LocalizedString { + .module("timeline.title", "Timeline · \(yearText(year))") + } + + static func rowAccessibility(region: String, range: String, days: Int) -> LocalizedString { + .module("timeline.row.accessibility") { + "\(region), \(range), \(LocalizedStrings.Common.dayCount(days).localized($0))" + } + } + } + + // MARK: Missing days + + enum MissingDays { + static let title: LocalizedString = .module("missingDays.title", "Missing days") + + static let done: LocalizedString = .module("missingDays.done", "Done") + + static let header: LocalizedString = .module("missingDays.header", "Days to backfill") + + static let footer: LocalizedString = .module( + "missingDays.footer", + "Tap a stretch to record where you were. Today is included until something logs it.", + ) + + static let emptyTitle: LocalizedString = .module("missingDays.empty.title", "All caught up") + + static let emptyDescription: LocalizedString = .module( + "missingDays.empty.description", + "Every day this year has something logged.", + ) + } + + // MARK: Missing-day banner + + enum MissingBanner { + static func compact(count: Int) -> LocalizedString { + count == 1 + ? .module("missing.banner.compact.one", "1 day needs a location") + : .module("missing.banner.compact.other", "\(count) days need a location") + } + + static let accessibilityHint: LocalizedString = .module( + "missing.banner.accessibilityHint", + "Opens the list of days that still need logging.", + ) + } + + // MARK: Widgets + + enum Widget { + static let todayTitle: LocalizedString = .module("widget.today.title", "Today") + + static let todayEmpty: LocalizedString = .module("widget.today.empty", "Nothing logged yet") + + static func yearTitle(year: Int) -> LocalizedString { + .module("widget.year.title", "Days in \(yearText(year))") + } + + static let yearEmpty: LocalizedString = .module("widget.year.empty", "No days logged") + } +} + +// MARK: Helpers + +/// Year without a grouping separator ("2026", not "2,026"). +private func yearText(_ year: Int) -> String { + year.formatted(.number.grouping(.never)) +} diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift deleted file mode 100644 index 4a9c0be..0000000 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ /dev/null @@ -1,898 +0,0 @@ -import Foundation -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 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") - } - - /// "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 - - /// The Primary tab's masthead wordmark. - static var primaryTitle: String { - localized("primary.title") - } - - static var primaryTimeline: String { - localized("primary.timeline") - } - - 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: 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: Settings debug - - static var settingsDebugHeader: String { - localized("settings.debug.header") - } - - static var settingsDebugLogsLink: String { - localized("settings.debug.logsLink") - } - - static var settingsDebugFooter: String { - localized("settings.debug.footer") - } - - static var settingsDebugLogsTitle: String { - localized("settings.debug.logsTitle") - } - - static var settingsDebugInspectorLink: String { - localized("settings.debug.inspectorLink") - } - - static var settingsDebugInspectorTitle: String { - localized("settings.debug.inspectorTitle") - } - - // 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 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, - ) -> String { - String( - localized: "settings.backup.imported.message", - defaultValue: "Imported \(samples) location samples, \(evidence) pieces of evidence, and \(manualDays) manual days.", - 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 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, - ) - } - - 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, - ) -> 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: 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: 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/Widgets/TodayAccessoryViews.swift b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift index 7fd96ed..f60aaa3 100644 --- a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift +++ b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore import WidgetKit @@ -23,7 +24,7 @@ public struct TodayInlineAccessoryView: View { systemImage: first.style.symbolName, ) } else { - Label(Strings.widgetTodayEmpty, systemImage: "location.slash") + Label(LocalizedStrings.Widget.todayEmpty.localized, systemImage: "location.slash") } } } @@ -59,7 +60,7 @@ public struct TodayCircularAccessoryView: View { } private var accessibilityText: String { - guard !regions.isEmpty else { return Strings.widgetTodayEmpty } + guard !regions.isEmpty else { return LocalizedStrings.Widget.todayEmpty.localized } return regions.map(\.localizedName).joined(separator: ", ") } } diff --git a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift index ed906eb..37be305 100644 --- a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -21,7 +22,7 @@ public struct TodayWidgetView: View { public var body: some View { VStack(alignment: .leading, spacing: UIConstants.Spacings.small) { HStack(alignment: .firstTextBaseline) { - Text(Strings.widgetTodayTitle) + Text(localized: .widget.todayTitle) .font(.caption2.weight(.semibold)) .textCase(.uppercase) .tracking(1) @@ -89,7 +90,7 @@ public struct TodayWidgetView: View { .font(.title3) .foregroundStyle(.tertiary) .accessibilityHidden(true) - Text(Strings.widgetTodayEmpty) + Text(localized: .widget.todayEmpty) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) } diff --git a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift index 0ec7501..c2d2d2a 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -20,8 +21,11 @@ public struct YearTotalsRectangularAccessoryView: View { public var body: some View { if ranked.isEmpty { - Label(Strings.widgetYearEmpty, systemImage: "calendar.badge.exclamationmark") - .font(.caption) + Label( + LocalizedStrings.Widget.yearEmpty.localized, + systemImage: "calendar.badge.exclamationmark", + ) + .font(.caption) } else { VStack(alignment: .leading, spacing: 0) { ForEach(ranked) { entry in @@ -40,7 +44,7 @@ public struct YearTotalsRectangularAccessoryView: View { } .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.regionDaysAccessibility( + .common.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 2ffc8e6..fcedcae 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import WhereCore @@ -22,7 +23,7 @@ public struct YearTotalsWidgetView: View { public var body: some View { VStack(alignment: .leading, spacing: UIConstants.Spacings.small) { - Text(Strings.widgetYearTitle(year: snapshot.year)) + Text(localized: .widget.yearTitle(year: snapshot.year)) .font(.caption2.weight(.semibold)) .textCase(.uppercase) .tracking(1) @@ -60,7 +61,7 @@ public struct YearTotalsWidgetView: View { } .accessibilityElement(children: .combine) .accessibilityLabel( - Strings.regionDaysAccessibility( + .common.regionDaysAccessibility( region: entry.region.localizedName, days: entry.days, ), @@ -75,7 +76,7 @@ public struct YearTotalsWidgetView: View { .font(.title3) .foregroundStyle(.tertiary) .accessibilityHidden(true) - Text(Strings.widgetYearEmpty) + Text(localized: .widget.yearEmpty) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) } diff --git a/Where/WhereUI/Tests/LocalizedStringsTests.swift b/Where/WhereUI/Tests/LocalizedStringsTests.swift new file mode 100644 index 0000000..6999b1a --- /dev/null +++ b/Where/WhereUI/Tests/LocalizedStringsTests.swift @@ -0,0 +1,110 @@ +import Foundation +import LocalizationKit +import Testing +@testable import WhereUI + +/// Verifies the WhereUI string catalog is actually wired up (deferred lookups +/// resolve to English values, not raw keys), that plural variations are +/// honored, and that years are formatted without a grouping separator. +struct LocalizedStringsTests { + @Test func simpleKeysResolveToCatalogValues() { + #expect(LocalizedStrings.Tabs.elsewhere.localized == "Elsewhere") + #expect(LocalizedStrings.Primary.title.localized == "Where") + #expect(LocalizedStrings.Common.loadErrorTitle.localized == "Couldn't load your year") + #expect(LocalizedStrings.Common.ok.localized == "OK") + #expect(LocalizedStrings.ManualEntry.saveErrorTitle.localized == "Couldn't save that day") + #expect(LocalizedStrings.Primary.elsewhereOnlyTitle + .localized == "Nothing in your headline spots") + } + + @Test func explicitLocaleConfigResolvesAgainstThatLocale() { + let english = LocalizationConfig(locale: Locale(identifier: "en")) + #expect(LocalizedStrings.Tabs.primary.localized(english) == "Primary") + } + + @Test func elsewhereOnlyDescriptionUsesPluralVariations() { + #expect( + LocalizedStrings.Primary.elsewhereOnlyDescription(count: 1).localized + == + "1 day logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + ) + #expect( + LocalizedStrings.Primary.elsewhereOnlyDescription(count: 9).localized + == + "9 days logged this year, but none in a headline spot yet. Peek at the Elsewhere tab.", + ) + } + + @Test func dayCountUsesPluralVariations() { + #expect(LocalizedStrings.Common.dayCount(1).localized == "1 day") + #expect(LocalizedStrings.Common.dayCount(5).localized == "5 days") + } + + @Test func dayUnitUsesPluralVariations() { + #expect(LocalizedStrings.Common.dayUnit(1).localized == "day") + #expect(LocalizedStrings.Common.dayUnit(2).localized == "days") + } + + @Test func yearsAreFormattedWithoutGroupingSeparator() { + #expect(LocalizedStrings.Timeline.title(year: 2026).localized == "Timeline · 2026") + #expect(LocalizedStrings.Calendar.title(year: 2026).localized == "Calendar · 2026") + #expect(LocalizedStrings.Settings.Data.erase(year: 2026).localized == "Erase 2026 data") + } + + @Test func calendarStringsResolveToCatalogValues() { + #expect(LocalizedStrings.Primary.calendar.localized == "Calendar") + #expect(LocalizedStrings.Calendar.unavailableDescription.localized + == "Your year data isn't available right now.") + } + + @Test func interpolatedStringsSubstituteArguments() { + #expect(LocalizedStrings.Primary.emptyTitle(year: 2024) + .localized == "No travels logged for 2024") + } + + @Test func missingBannerCompactUsesPluralVariations() { + #expect(LocalizedStrings.MissingBanner.compact(count: 1) + .localized == "1 day needs a location") + #expect(LocalizedStrings.MissingBanner.compact(count: 8) + .localized == "8 days need a location") + } + + @Test func relabelStringsResolveToCatalogValues() { + #expect(LocalizedStrings.Relabel.title.localized == "Fix this day") + #expect(LocalizedStrings.Relabel.regionsHeader.localized == "Where were you?") + #expect(LocalizedStrings.Relabel.reset.localized == "Reset to GPS-detected location") + #expect(LocalizedStrings.Secondary.Region.emptyTitle.localized == "Nothing to fix") + #expect(LocalizedStrings.Secondary.Region.current(regions: "California") + .localized == "Counts as California") + } + + @Test func backupStringsResolveToCatalogValues() { + #expect(LocalizedStrings.Settings.Backup.header.localized == "Backup") + #expect(LocalizedStrings.Settings.Backup.export.localized == "Export data") + #expect(LocalizedStrings.Settings.Backup.importData.localized == "Import data") + #expect(LocalizedStrings.Settings.Backup.merge.localized == "Merge") + #expect(LocalizedStrings.Settings.Backup.replace.localized == "Replace all") + #expect(LocalizedStrings.Settings.Backup.importedTitle.localized == "Backup imported") + } + + @Test func backupImportedMessageSubstitutesAllThreeCountsInOrder() { + #expect( + LocalizedStrings.Settings.Backup.importedMessage(samples: 3, evidence: 2, manualDays: 5) + .localized + == + "Imported 3 location samples, 2 pieces of evidence, and 5 manual days.", + ) + } + + @Test func debugSettingsStringsResolveToCatalogValues() { + #expect(LocalizedStrings.Settings.Debug.header.localized == "Developer") + #expect(LocalizedStrings.Settings.Debug.logsLink.localized == "Logs") + #expect(LocalizedStrings.Settings.Debug.logsTitle.localized == "Logs") + #expect(LocalizedStrings.Settings.Debug.inspectorLink.localized == "SwiftData Inspector") + #expect(LocalizedStrings.Settings.Debug.inspectorTitle.localized == "SwiftData") + #expect( + LocalizedStrings.Settings.Debug.footer.localized + == "On-device logs and data tools. Debug builds only.", + ) + } +} diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift deleted file mode 100644 index 7d6fe0d..0000000 --- a/Where/WhereUI/Tests/StringsTests.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Testing -@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.primaryTitle == "Where") - #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 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 backupImportedMessageSubstitutesAllThreeCountsInOrder() { - #expect( - Strings.settingsBackupImportedMessage(samples: 3, evidence: 2, manualDays: 5) - == - "Imported 3 location samples, 2 pieces of evidence, and 5 manual days.", - ) - } - - @Test func debugSettingsStringsResolveToCatalogValues() { - #expect(Strings.settingsDebugHeader == "Developer") - #expect(Strings.settingsDebugLogsLink == "Logs") - #expect(Strings.settingsDebugLogsTitle == "Logs") - #expect(Strings.settingsDebugInspectorLink == "SwiftData Inspector") - #expect(Strings.settingsDebugInspectorTitle == "SwiftData") - #expect( - Strings.settingsDebugFooter - == "On-device logs and data tools. Debug builds only.", - ) - } -} diff --git a/Where/WhereUI/Tests/WidgetViewsTests.swift b/Where/WhereUI/Tests/WidgetViewsTests.swift index a017eb0..98655ff 100644 --- a/Where/WhereUI/Tests/WidgetViewsTests.swift +++ b/Where/WhereUI/Tests/WidgetViewsTests.swift @@ -1,3 +1,4 @@ +import LocalizationKit import SwiftUI import Testing import WhereCore @@ -61,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(LocalizedStrings.Widget.todayTitle.localized == "Today") + #expect(LocalizedStrings.Widget.todayEmpty.localized == "Nothing logged yet") + #expect(LocalizedStrings.Widget.yearTitle(year: 2026).localized == "Days in 2026") + #expect(LocalizedStrings.Widget.yearEmpty.localized == "No days logged") } // MARK: - Lock-screen accessories diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 849d05f..1d0a205 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -12,7 +12,7 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app-extension target** ([`Project.swift`](../../Project.swift), `Where/WhereWidgets/Sources`, bundle ID `com.stuff.where.widgets`). -- Depends on **WhereCore** (snapshot types + App Group store), +- Depends on **LocalizationKit**, **WhereCore** (snapshot types + App Group store), **WhereUI** (widget views + in-widget localized strings), and **LogKit** (via `WhereLog.channel(.whereWidgets)`). - Must **not** import SwiftData, open the user's store, or duplicate aggregation @@ -34,8 +34,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature content views live in **WhereUI**. - [`WidgetSnapshotFixtures`](Sources/WidgetSnapshotFixtures.swift) – shared `DayAggregator().calendar` and snapshot builders for the provider + previews. -- [`WidgetStrings`](Sources/WidgetStrings.swift) – gallery name/description from - this extension's `Localizable.xcstrings` (`bundle: .module`). +- [`LocalizedStrings`](Sources/LocalizedStrings.swift) – gallery name/description + from this extension's `Localizable.xcstrings` via `.module(_:_:)`. ## Behaviors to preserve @@ -63,8 +63,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - Follow root rules: exhaustive enum switches, small named structs, no closure `Binding(get:set:)`. -- In-widget strings come from **WhereUI** (`Strings.*`); gallery strings from - **this extension's catalog** (`WidgetStrings`). +- In-widget strings come from **WhereUI** (`LocalizedStrings`); gallery strings from + **this extension's catalog** (`LocalizedStrings.Gallery.*`). - Every widget ships `#Preview` timelines (DEBUG, bottom of file). ## Testing gaps (documented) diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 81dd24c..c4bddae 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -34,16 +34,16 @@ app never wakes. ## Localization - **In-widget copy** — resolved from [`WhereUI`](../WhereUI/)'s - `Localizable.xcstrings` (`Strings.widgetTodayTitle`, etc.). + `Localizable.xcstrings` (`LocalizedStrings.Widget.*`, etc.). - **Widget gallery name/description** — resolved from this extension's - [`Resources/Localizable.xcstrings`](Resources/Localizable.xcstrings) via - `WidgetStrings` (`bundle: .module`). + [`Sources/Resources/Localizable.xcstrings`](Sources/Resources/Localizable.xcstrings) + via [`LocalizedStrings.swift`](Sources/LocalizedStrings.swift). ## Installation `WhereWidgets` is a Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.widgets`). -It depends on **WhereCore**, **WhereUI**, and **LogKit**. The main **Where** +It depends on **LocalizationKit**, **WhereCore**, **WhereUI**, and **LogKit**. The main **Where** app embeds the extension and shares the App Group entitlement. ## Previews diff --git a/Where/WhereWidgets/Sources/LocalizedString+Module.swift b/Where/WhereWidgets/Sources/LocalizedString+Module.swift new file mode 100644 index 0000000..6949043 --- /dev/null +++ b/Where/WhereWidgets/Sources/LocalizedString+Module.swift @@ -0,0 +1,19 @@ +import LocalizationKit + +extension LocalizedString { + /// A WhereWidgets catalog string — delegates to ``LocalizedString/catalog(_:_:bundle:)`` + /// with `bundle: .module`. + static func module( + _ key: StaticString, + _ defaultValue: String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, defaultValue, bundle: .module) + } + + static func module( + _ key: StaticString, + _ defaultValue: @Sendable @escaping (LocalizationConfig?) -> String.LocalizationValue, + ) -> LocalizedString { + .catalog(key, bundle: .module, defaultValue) + } +} diff --git a/Where/WhereWidgets/Sources/LocalizedStrings.swift b/Where/WhereWidgets/Sources/LocalizedStrings.swift new file mode 100644 index 0000000..18d6489 --- /dev/null +++ b/Where/WhereWidgets/Sources/LocalizedStrings.swift @@ -0,0 +1,29 @@ +import LocalizationKit + +/// Catalog-backed strings for the WhereWidgets extension. +/// +/// Gallery name/description strings shown in the WidgetKit picker. Runtime +/// widget content is localized in WhereUI. Swift is the source of truth for +/// keys and English defaults; the sibling `Resources/Localizable.xcstrings` owns +/// translations. The root `./localize` script reconciles the catalog from this +/// file. +enum LocalizedStrings { + enum Gallery { + static let todayName: LocalizedString = .module("widget.gallery.today.name", "Today") + + static let todayDescription: LocalizedString = .module( + "widget.gallery.today.description", + "Which region today counts for.", + ) + + static let yearTotalsName: LocalizedString = .module( + "widget.gallery.yearTotals.name", + "Day Counts", + ) + + static let yearTotalsDescription: LocalizedString = .module( + "widget.gallery.yearTotals.description", + "Days spent in each region this year.", + ) + } +} diff --git a/Where/WhereWidgets/Resources/Localizable.xcstrings b/Where/WhereWidgets/Sources/Resources/Localizable.xcstrings similarity index 100% rename from Where/WhereWidgets/Resources/Localizable.xcstrings rename to Where/WhereWidgets/Sources/Resources/Localizable.xcstrings diff --git a/Where/WhereWidgets/Sources/TodayWidget.swift b/Where/WhereWidgets/Sources/TodayWidget.swift index b321250..10be733 100644 --- a/Where/WhereWidgets/Sources/TodayWidget.swift +++ b/Where/WhereWidgets/Sources/TodayWidget.swift @@ -13,8 +13,8 @@ struct TodayWidget: Widget { StaticConfiguration(kind: Self.kind, provider: WhereWidgetProvider()) { entry in TodayWidgetContent(entry: entry) } - .configurationDisplayName(WidgetStrings.todayGalleryName) - .description(WidgetStrings.todayGalleryDescription) + .configurationDisplayName(LocalizedStrings.Gallery.todayName.localized) + .description(LocalizedStrings.Gallery.todayDescription.localized) .supportedFamilies([.systemSmall, .accessoryInline, .accessoryCircular]) } } diff --git a/Where/WhereWidgets/Sources/WidgetStrings.swift b/Where/WhereWidgets/Sources/WidgetStrings.swift deleted file mode 100644 index b44bfa4..0000000 --- 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 b6b524d..6c71a4f 100644 --- a/Where/WhereWidgets/Sources/YearTotalsWidget.swift +++ b/Where/WhereWidgets/Sources/YearTotalsWidget.swift @@ -13,8 +13,8 @@ struct YearTotalsWidget: Widget { StaticConfiguration(kind: Self.kind, provider: WhereWidgetProvider()) { entry in YearTotalsWidgetContent(entry: entry) } - .configurationDisplayName(WidgetStrings.yearTotalsGalleryName) - .description(WidgetStrings.yearTotalsGalleryDescription) + .configurationDisplayName(LocalizedStrings.Gallery.yearTotalsName.localized) + .description(LocalizedStrings.Gallery.yearTotalsDescription.localized) .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular]) } } diff --git a/localize b/localize new file mode 100755 index 0000000..35637b4 --- /dev/null +++ b/localize @@ -0,0 +1,503 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# localize — keep .xcstrings catalogs in sync with Swift source. +# +# Swift is the single source of truth for user-facing strings: each module's +# `LocalizedStrings.swift` declares every key and its English default via a +# literal `.module("", "")` factory call — or, for composed strings, +# `.module("") { "" }`, and as a fallback the raw +# `String(localized: "", defaultValue: "", …)`. This script extracts +# those calls and reconciles the sibling `Resources/Localizable.xcstrings` +# catalog so the two can't drift: +# +# • ADD keys present in Swift but missing from the catalog. +# • PRUNE catalog keys no longer referenced in Swift (orphans). +# • UPDATE the English value of a *simple* key when the Swift default changed. +# • PRESERVE any key that carries plural `variations` or is parameterized +# (interpolated) — those stay hand-authored in the catalog — plus +# every non-English translation, untouched. +# +# Duplicate keys in the same `LocalizedStrings.swift` abort reconciliation +# (same fail-loudly rule as a non-literal key or default). +# +# It writes the catalog byte-for-byte in Xcode's own format (pretty-printed +# JSON: 2-space indent, "key" : value, case-insensitively sorted keys, no +# trailing newline), so a reconciled, already-in-sync catalog produces no diff. +# +# Commands: +# ./localize Reconcile and write all catalogs. +# ./localize --lint Check only; exit non-zero (listing drift) if any +# catalog would change. Used by CI. +# ./localize --git-add Reconcile, write, then `git add` changed catalogs. +# Used by the pre-commit hook. +# ./localize --help Show this help. +# +# No Tuist/Xcode/build is needed, so it runs in the pre-commit hook and on +# Linux CI. + +require "json" + +REPO_ROOT = File.expand_path("..", __FILE__) +EXCLUDED_DIRS = %w[Derived .build .git .cursor .claude].freeze + +# Swift type -> printf-style specifier used when *adding* a new parameterized +# key. Existing keys are preserved, so this only seeds best-effort defaults that +# a human refines in Xcode. +TYPE_SPECIFIERS = { + "Int" => "%lld", "Int64" => "%lld", "Int32" => "%d", "UInt" => "%llu", + "Double" => "%f", "Float" => "%f", "CGFloat" => "%f", + "String" => "%@", "Substring" => "%@", +}.freeze + +# --- Swift extraction ------------------------------------------------------- + +# One key-defining call discovered in Swift (`.module(…)` or `String(localized:)`). +ExtractedKey = Struct.new(:key, :simple_value, :interpolated, keyword_init: true) + +class SwiftParser + def initialize(source) + # Strip whole-line comments so doc comments that *mention* the API (or + # `// MARK:` markers) can't be mistaken for real calls. + @source = source.each_line.reject { |line| line =~ %r{^\s*//} }.join + end + + # Returns { key => ExtractedKey }, raising on any call we can't statically + # read (a dynamic key or default value) so drift can't slip through, and on + # duplicate keys within the same file. + def extract + params = parameter_types_by_position + keys = {} + first_at = {} + scan_calls do |call_start, key, value_segments| + if first_at.key?(key) + raise duplicate_key_error(key, first_at[key], call_start) + end + + first_at[key] = call_start + simple = value_segments.all? { |seg| seg[0] == :text } + key_struct = ExtractedKey.new( + key: key, + simple_value: value_segments.map { |seg| seg[1] }.join, + interpolated: !simple, + ) + keys[key] = simple ? key_struct : key_struct.tap { |k| k.simple_value = format_value(value_segments, params, call_start) } + end + keys + end + + private + + # Yields [call_start_index, key, value_segments] for every key-defining call. + # Each has a literal key followed by a literal default value, in one of: + # • `.module("", "")` — the LocalizedStrings factory. + # • `.module("") { "" }` — its config-aware overload + # (composed strings); the default is the first literal in the closure. + # • `String(localized: "", defaultValue: "", …)` — the raw + # initializer, still understood as a fallback. + # value_segments is an array of [:text, "..."] / [:interp, "expr"]. + def scan_calls + index = 0 + while (match = @source.match(/\.module\(\s*|String\(\s*localized:\s*/, index)) + call_start = match.begin(0) + cursor = match.end(0) + module_form = match[0].start_with?(".module") + unless @source[cursor] == '"' + raise "localize: non-literal key in #{module_form ? '.module(…)' : 'String(localized:)'} near:\n#{context(call_start)}" + end + key, cursor = read_string_literal(cursor) + cursor = if module_form + seek_module_default(cursor, call_start) + else + expect_label(cursor, "defaultValue:", call_start) + end + unless @source[cursor] == '"' + raise "localize: non-literal default value for key #{key.first[1].inspect} near:\n#{context(call_start)}" + end + value_segments, cursor = read_string_segments(cursor) + yield call_start, key.first[1], value_segments + index = cursor + end + end + + # After a `.module` key, advance to the opening quote of its default value: + # either `, ""` (the value factory) or the first string literal in a + # `) { "" … }` trailing closure (the config-aware factory). + def seek_module_default(cursor, call_start) + cursor += 1 while @source[cursor] =~ /\s/ + case @source[cursor] + when "," + cursor += 1 + cursor += 1 while @source[cursor] =~ /\s/ + when ")" + cursor += 1 while @source[cursor] && @source[cursor] != '"' + else + raise "localize: expected ',' or a trailing closure after .module key near:\n#{context(call_start)}" + end + cursor + end + + # Advance past whitespace + a comma to the given label, returning the index + # just after it. + def expect_label(cursor, label, call_start) + cursor += 1 while @source[cursor] =~ /\s/ || @source[cursor] == "," + unless @source[cursor, label.length] == label + raise "localize: expected #{label} in String(localized:) near:\n#{context(call_start)}" + end + cursor += label.length + cursor += 1 while @source[cursor] =~ /\s/ + cursor + end + + # Parse a string literal whose value has no interpolation, returning + # [[[:text, value]], next_index]. Used for the key. + def read_string_literal(start) + segments, nxt = read_string_segments(start) + if segments.any? { |seg| seg[0] == :interp } + raise "localize: interpolated key is not allowed near:\n#{context(start)}" + end + [[[:text, segments.map { |s| s[1] }.join]], nxt] + end + + # Parse a Swift string literal starting at the opening quote (index `start`), + # returning [segments, index_after_closing_quote]. Handles \(interpolation) + # and backslash escapes. + def read_string_segments(start) + i = start + 1 + segments = [] + buffer = +"" + flush = -> { segments << [:text, buffer.dup] unless buffer.empty?; buffer.clear } + while i < @source.length + ch = @source[i] + if ch == "\\" && @source[i + 1] == "(" + flush.call + expr, i = read_interpolation(i + 2) + segments << [:interp, expr] + elsif ch == "\\" + buffer << unescape(@source[i + 1]) + i += 2 + elsif ch == '"' + i += 1 + break + else + buffer << ch + i += 1 + end + end + flush.call + [segments, i] + end + + # Read a \( ... ) interpolation body starting just after the "(", returning + # [expr_string, index_after_closing_paren]. Tracks nested parens. + def read_interpolation(start) + depth = 1 + i = start + expr = +"" + while i < @source.length && depth.positive? + ch = @source[i] + depth += 1 if ch == "(" + depth -= 1 if ch == ")" + break if depth.zero? + + expr << ch + i += 1 + end + [expr.strip, i + 1] + end + + def unescape(char) + { "n" => "\n", "t" => "\t", "r" => "\r", "\"" => "\"", "\\" => "\\", "0" => "\0" }.fetch(char, char) + end + + # Map each `func name(label: Type, ...)` to its position and a label->Type + # map, so a call's interpolations can pick a specifier. + def parameter_types_by_position + decls = [] + index = 0 + while (match = @source.match(/\bfunc\s+\w+\(([^)]*)\)/, index)) + types = {} + match[1].split(",").each do |param| + next unless (m = param.strip.match(/(?:\w+\s+)?(\w+)\s*:\s*([\w.<>?]+)/)) + + types[m[1]] = m[2] + end + decls << [match.begin(0), types] + index = match.end(0) + end + decls + end + + # Build the catalog source value for an interpolated default, substituting a + # printf specifier per \(expr). Best-effort; only used for *new* keys. + def format_value(segments, decls, call_start) + enclosing = decls.select { |start, _| start < call_start }.max_by(&:first) + types = enclosing ? enclosing[1] : {} + interps = segments.count { |seg| seg[0] == :interp } + position = 0 + segments.map do |kind, payload| + next payload if kind == :text + + position += 1 + specifier = specifier_for(payload, types) + interps > 1 ? positional(specifier, position) : specifier + end.join + end + + def specifier_for(expr, types) + return TYPE_SPECIFIERS.fetch(types[expr], "%@") if expr =~ /\A\w+\z/ + + "%@" # complex expression (e.g. a helper call) — assume String. + end + + def positional(specifier, position) + specifier.sub(/\A%/, "%#{position}$") + end + + def context(index) + @source[[index - 40, 0].max, 120] + end + + def duplicate_key_error(key, first_start, second_start) + "localize: duplicate key #{key.inspect} in the same LocalizedStrings.swift\n" \ + " first: …#{context(first_start).strip}\n" \ + " second: …#{context(second_start).strip}" + end +end + +# --- Catalog reconciliation ------------------------------------------------- + +class Catalog + attr_reader :added, :pruned, :updated + + def initialize(path) + @path = path + @original = File.read(path, encoding: "UTF-8") + @data = JSON.parse(@original) + @added = [] + @pruned = [] + @updated = [] + end + + def reconcile(extracted) + strings = @data["strings"] ||= {} + + extracted.each do |key, info| + if strings.key?(key) + update_existing(strings[key], key, info) + else + strings[key] = new_entry(info.simple_value) + @added << key + end + end + + (strings.keys - extracted.keys).each do |key| + next if key.empty? # Xcode keeps the "" placeholder entry. + + strings.delete(key) + @pruned << key + end + end + + def changed? + serialize != @original + end + + def changes? + !(@added.empty? && @pruned.empty? && @updated.empty?) + end + + def write + File.write(@path, serialize) + end + + def relative_path + @path.delete_prefix("#{REPO_ROOT}/") + end + + private + + # Only ever rewrite the English value of a *simple* (non-plural, + # non-interpolated) entry. Plurals, parameterized keys, and every non-English + # localization are left exactly as authored. + def update_existing(entry, key, info) + return if info.interpolated + + unit = entry.dig("localizations", "en", "stringUnit") + return if unit.nil? # variations / no en unit -> preserve. + return if unit["value"] == info.simple_value + + unit["value"] = info.simple_value + @updated << key + end + + def new_entry(value) + { + "extractionState" => "manual", + "localizations" => { + "en" => { "stringUnit" => { "state" => "translated", "value" => value } }, + }, + } + end + + # Serialize matching Xcode's pretty-printed JSON output, so an in-sync + # catalog round-trips with no diff. + def serialize + FoundationJSON.generate(@data) + end +end + +# Reproduces Xcode's .xcstrings formatting: pretty-printed JSON with a 2-space +# indent, `"key" : value` spacing, case-insensitively sorted keys, and no +# trailing newline. +module FoundationJSON + module_function + + def generate(object) + render(object, 0) + end + + def render(object, level) + case object + when Hash then render_hash(object, level) + when Array then render_array(object, level) + when String then string(object) + when Integer then object.to_s + when Float then object.to_s + when true then "true" + when false then "false" + when nil then "null" + else raise "localize: cannot serialize #{object.class}" + end + end + + def render_hash(hash, level) + indent = " " * level + return "{\n\n#{indent}}" if hash.empty? + + child = " " * (level + 1) + # Xcode orders keys case-insensitively (so "imported" precedes + # "importStrategy"), with the raw key as a stable tiebreaker. + body = hash.keys.sort_by { |key| [key.downcase, key] }.map do |key| + "#{child}#{string(key)} : #{render(hash[key], level + 1)}" + end.join(",\n") + "{\n#{body}\n#{indent}}" + end + + def render_array(array, level) + indent = " " * level + return "[\n\n#{indent}]" if array.empty? + + child = " " * (level + 1) + body = array.map { |value| "#{child}#{render(value, level + 1)}" }.join(",\n") + "[\n#{body}\n#{indent}]" + end + + def string(value) + out = +'"' + value.each_char do |ch| + case ch + when '"' then out << '\\"' + when "\\" then out << "\\\\" + when "\n" then out << '\\n' + when "\t" then out << '\\t' + when "\r" then out << '\\r' + when "\b" then out << '\\b' + when "\f" then out << '\\f' + else + code = ch.ord + out << (code < 0x20 ? format('\\u%04x', code) : ch) + end + end + out << '"' + out + end +end + +# --- Driver ----------------------------------------------------------------- + +class Localize + def run(args) + if args.include?("--help") || args.include?("-h") + header = File.read(__FILE__, encoding: "UTF-8")[/\A.*?(?=\nrequire )/m] + puts header.lines + .drop_while { |line| !line.start_with?("# localize") } + .map { |line| line.sub(/^# ?/, "") } + .join + return 0 + end + + lint = args.include?("--lint") + git_add = args.include?("--git-add") + + catalogs = discover_catalogs + if catalogs.empty? + warn "localize: no LocalizedStrings.swift found." + return 0 + end + + drifted = [] + catalogs.each do |swift_path, catalog_path| + extracted = SwiftParser.new(File.read(swift_path, encoding: "UTF-8")).extract + catalog = Catalog.new(catalog_path) + catalog.reconcile(extracted) + next unless catalog.changed? + + drifted << catalog + report(catalog) + end + + return 0 if drifted.empty? + + if lint + warn "\nlocalize: catalog drift detected. Run ./localize to reconcile." + return 1 + end + + drifted.each(&:write) + stage(drifted) if git_add + 0 + end + + private + + # Map each LocalizedStrings.swift to its module's + # /Resources/Localizable.xcstrings. + def discover_catalogs + Dir.glob(File.join(REPO_ROOT, "**", "LocalizedStrings.swift")) + .reject { |path| EXCLUDED_DIRS.any? { |dir| path.include?("/#{dir}/") } } + .sort + .map do |swift_path| + sources = swift_path[%r{\A(.*/Sources)/}, 1] + unless sources + abort "localize: #{rel(swift_path)} is not under a Sources/ directory." + end + catalog = File.join(sources, "Resources", "Localizable.xcstrings") + unless File.exist?(catalog) + abort "localize: no catalog at #{rel(catalog)} for #{rel(swift_path)}." + end + [swift_path, catalog] + end + end + + def report(catalog) + catalog.added.sort.each { |key| puts " + #{catalog.relative_path}: #{key}" } + catalog.pruned.sort.each { |key| puts " - #{catalog.relative_path}: #{key}" } + catalog.updated.sort.each { |key| puts " ~ #{catalog.relative_path}: #{key}" } + # A formatting-only change (no key add/prune/update) still means the file + # wasn't written by this tool; rewrite it to normalize. + puts " * #{catalog.relative_path}: reformatted" unless catalog.changes? + end + + def stage(catalogs) + catalogs.each do |catalog| + system("git", "add", "--", catalog.relative_path, err: File::NULL) + end + end + + def rel(path) + path.delete_prefix("#{REPO_ROOT}/") + end +end + +exit(Localize.new.run(ARGV))