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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,23 @@ private let stuffPackage = Package.local(path: .relativeToRoot("."))
/// Xcode falls back to its defaults.
private let developmentTeam = Environment.developmentTeam.getString(default: "")

private let projectSettings: Settings? = developmentTeam.isEmpty
? nil
: .settings(base: ["DEVELOPMENT_TEAM": .string(developmentTeam)])
/// Base build settings applied to every Tuist-generated target.
///
/// `STRING_CATALOG_GENERATE_SYMBOLS` turns on Xcode's type-safe String Catalog
/// symbol generation for the app and app-extension targets (Where, WhereWidgets,
/// WhereShareExtension, …). The SwiftPM package targets declared in `Package.swift`
/// (WhereUI, WhereCore, RegionKit, LifecycleKit) get symbol generation automatically
/// from the toolchain, so this only needs to reach the Tuist-native targets.
///
/// `DEVELOPMENT_TEAM` is threaded in from the environment when present (see above).
private let projectSettings: Settings = .settings(
base: developmentTeam.isEmpty
? ["STRING_CATALOG_GENERATE_SYMBOLS": "YES"]
: [
"STRING_CATALOG_GENERATE_SYMBOLS": "YES",
"DEVELOPMENT_TEAM": .string(developmentTeam),
],
)

/// App Group shared by the Where app, its widget extension, and its share
/// extension so every process sees the same on-disk SwiftData store (see
Expand Down
7 changes: 2 additions & 5 deletions Shared/LifecycleKit/Sources/LifecycleFailureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ public struct LifecycleFailureView: View {

public var body: some View {
ContentUnavailableView {
Label(
String(localized: "failure.launch.title", bundle: .module),
systemImage: "exclamationmark.triangle",
)
Label(.failureLaunchTitle, systemImage: "exclamationmark.triangle")
} description: {
Text(failure.error.localizedDescription)
} actions: {
Button(String(localized: "failure.launch.retry", bundle: .module), action: retry)
Button(.failureLaunchRetry, action: retry)
.buttonStyle(.borderedProminent)
}
}
Expand Down
50 changes: 32 additions & 18 deletions Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,26 +115,40 @@ Rules the code enforces and agents must preserve:

## Localization

All user-facing copy resolves through module string catalogs — no literals in
views or thrown errors.

- **WhereUI:** funnel every string through `Strings.swift` (keys in the module
`Localizable.xcstrings`, `bundle: .module`). Counts use catalog plural
variations; years use a grouping-free number style ("2026", not "2,026").
- **WhereCore:** user-visible errors use static
`String(localized:bundle: .module)` keys in its own catalog.
- **RegionKit:** region names (`Region.localizedName`) use static
`String(localized:bundle: .module)` keys in RegionKit's own catalog.
All user-facing copy resolves through module String Catalogs via Xcode's
generated `LocalizedStringResource` symbols — no string literals in views or
thrown errors, and no raw keys or `bundle:` arguments in code. Symbol generation
is on for every catalog (`STRING_CATALOG_GENERATE_SYMBOLS`, set project-wide for
the app and extensions in [`Project.swift`](../Project.swift); automatic for the
SwiftPM library targets), so a manual key `some.key` produces a `.someKey`
symbol and a removed or misspelled key is a compile error.

- **Add the key first.** Add it to the module's `Localizable.xcstrings` as a
**manual** key (the topmost `+`) — that is what generates the symbol — then
reference the symbol, never a raw string key.
- **Reference symbols directly** at the call site: `Text(.tabPrimary)`,
`String(localized: .commonOk)`, `Label(.evidenceAdd, systemImage:)`. Pass the
bare symbol where SwiftUI takes a `LocalizedStringResource` (`Text`, `Label`,
`Button`); use `String(localized: .symbol)` where a `String` is needed
(notification bodies, `errorDescription`, interpolated accessibility labels).
- **WhereUI:** strings that need composition or a `switch` — pluralization,
grouping-free years ("2026", not "2,026"), region-name lists, enum-driven
copy, composed accessibility labels — go through
[`WhereFormat`](WhereUI/Sources/Shared/WhereFormat.swift), which builds them
from the generated symbols. Everything else is a symbol referenced inline.
- **WhereCore / RegionKit:** user-visible errors and region names
(`Region.localizedName`) switch over the generated symbols
(`String(localized: .regionCalifornia)`) — no `bundle: .module`.
- **DEBUG-only UI** still gets catalog entries — don't bypass localization
because a surface is dev-only.
- **WhereWidgets:** gallery name/description live in the extension's own
catalog; in-widget copy reuses WhereUI `Strings`.
- **WhereShareExtension:** compose-sheet chrome lives in the extension's own
catalog (`ShareStrings`); evidence kind names reuse WhereUI's public
`EvidenceKind` presentation helpers.

Add the key to the catalog first, then reference it — never ship English
literals in SwiftUI `Text` or `errorDescription`.
- **WhereWidgets:** gallery name/description use the extension's own generated
symbols; in-widget copy comes from the shared WhereUI content views.
- **WhereShareExtension:** compose-sheet chrome uses the extension's own
generated symbols; evidence kind names reuse WhereUI's public `EvidenceKind`
presentation helpers.

Add the manual key to the catalog first, then reference its symbol — never ship
English literals in SwiftUI `Text` or `errorDescription`.

## Dates & presentation

Expand Down
15 changes: 9 additions & 6 deletions Where/RegionKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,19 @@ print(region.localizedName) // "California"
Region polygons ship in `Sources/Resources/*.geojson`; see
[`Sources/Resources/README.md`](Sources/Resources/README.md) for provenance and
fidelity notes. Region names resolve through RegionKit's own
`Localizable.xcstrings` (`Region.localizedName`, `bundle: .module`).
`Localizable.xcstrings` — `Region.localizedName` reads Xcode's generated
`LocalizedStringResource` symbols (`String(localized: .regionCalifornia)`), so
no bundle argument is needed.

## Adding a region

Add the `Region` case, then resolve the two compile errors it forces: a
`region.<rawValue>` entry in `Sources/Resources/Localizable.xcstrings` (for
`localizedName`) and a `Region.geometrySource` case — either
`.usStateFeature(name:)` (a feature already in `us-states.geojson`, no new file)
or `.bundledFile` with a new `<rawValue>.geojson` in `Sources/Resources/`. Add a
`RegionAttributorTests` spot-check.
`region.<rawValue>` entry in `Sources/Resources/Localizable.xcstrings` (added as
a **manual** key, which generates the `.region<RawValue>` symbol `localizedName`
switches on) and a `Region.geometrySource` case — either `.usStateFeature(name:)`
(a feature already in `us-states.geojson`, no new file) or `.bundledFile` with a
new `<rawValue>.geojson` in `Sources/Resources/`. Add a `RegionAttributorTests`
spot-check.

## Testing

Expand Down
21 changes: 10 additions & 11 deletions Where/RegionKit/Sources/Region.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,23 @@
/// User-facing name for this region, read from the `RegionKit`
/// string catalog (`Resources/Localizable.xcstrings`).
///
/// Uses `String(localized:)` with a literal key per case (rather
/// than `NSLocalizedString` with a runtime-composed
/// `"region.\(rawValue)"`) so Xcode's string-catalog extraction
/// tooling can statically find every key. Adding a new region
/// case is intentionally a compile error here until you add a
/// matching string catalog entry.
/// Uses Xcode's generated `LocalizedStringResource` symbol per case
/// (rather than a runtime-composed `"region.\(rawValue)"` key) so the
/// compiler enforces that every case has a catalog entry: adding a new
/// region case is a compile error here until its manual key is added to
/// the catalog, which is what produces the symbol.
public var localizedName: String {
switch self {
case .california:
String(localized: "region.california", bundle: .module)
String(localized: .regionCalifornia)

Check failure on line 32 in Where/RegionKit/Sources/Region.swift

View workflow job for this annotation

GitHub Actions / Build & Test (iOS)

type 'String.LocalizationValue' has no member 'regionCalifornia'
case .newYork:
String(localized: "region.newYork", bundle: .module)
String(localized: .regionNewYork)
case .canada:
String(localized: "region.canada", bundle: .module)
String(localized: .regionCanada)
case .europeanUnion:
String(localized: "region.europeanUnion", bundle: .module)
String(localized: .regionEuropeanUnion)
case .other:
String(localized: "region.other", bundle: .module)
String(localized: .regionOther)
}
}

Expand Down
4 changes: 3 additions & 1 deletion Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ internal shape.
- Library target in [`Package.swift`](../../Package.swift)
(`Where/WhereCore/Sources`); depended on by `WhereUI` and the `WhereWidgets`
extension. User-visible error strings ship in its own
`Sources/Resources/Localizable.xcstrings` (`bundle: .module`).
`Sources/Resources/Localizable.xcstrings` and resolve through Xcode's
generated symbols (`String(localized: .backupErrorManifestMissing)`) — no
`bundle: .module`.

## Shape & invariants

Expand Down
16 changes: 2 additions & 14 deletions Where/WhereCore/Sources/Backup/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,9 @@ public struct BackupService: Sendable {
public var errorDescription: String? {
switch self {
case .manifestMissing:
String(
localized: "backup.error.manifestMissing",
defaultValue: "This file isn't a Where backup (no manifest was found).",
bundle: .module,
)
String(localized: .backupErrorManifestMissing)
case let .unsupportedFormatVersion(version):
String(
format: String(
localized: "backup.error.unsupportedFormatVersion",
defaultValue:
"This backup was created by a newer version of Where (format %lld) and can't be imported.",
bundle: .module,
),
version,
)
String(localized: .backupErrorUnsupportedFormatVersion(version))
}
}
}
Expand Down
21 changes: 6 additions & 15 deletions Where/WhereCore/Sources/DailySummaryReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public actor DailySummaryReconciler {
.prefix(regionLimit)

guard !ranked.isEmpty else {
return String(localized: "summary.notification.body.empty", bundle: .module)
return String(localized: .summaryNotificationBodyEmpty)
}

return ranked
Expand All @@ -95,19 +95,10 @@ public actor DailySummaryReconciler {
}

private static func summaryFragment(region: Region, days: Int) -> String {
let count = String(
localized: "summary.notification.dayCount",
defaultValue: "\(days) days",
bundle: .module,
)
return String(
format: String(
localized: "summary.notification.regionDays",
defaultValue: "%1$@ in %2$@",
bundle: .module,
),
count,
region.localizedName,
)
// Two type-safe symbols composed in place: the plural day count
// (".summaryNotificationDayCount") rendered into the "<count> in <region>"
// shape (".summaryNotificationRegionDays"), replacing the old String(format:).
let dayCount = String(localized: .summaryNotificationDayCount(days))
return String(localized: .summaryNotificationRegionDays(dayCount, region.localizedName))
}
}
6 changes: 1 addition & 5 deletions Where/WhereCore/Sources/DataIssueAlertReconciler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,6 @@ public actor DataIssueAlertReconciler {
/// The alert body, pluralized on the issue count (e.g. "1 issue to resolve"
/// / "3 issues to resolve").
private static func body(count: Int) -> String {
String(
localized: "dataIssues.notification.body",
defaultValue: "\(count) issues to resolve",
bundle: .module,
)
String(localized: .dataIssuesNotificationBody(count))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling
components.minute = time.minute

let content = UNMutableNotificationContent()
content.title = String(localized: "summary.notification.title", bundle: .module)
content.title = String(localized: .summaryNotificationTitle)
content.body = body
content.sound = .default

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu
components.minute = time.minute

let content = UNMutableNotificationContent()
content.title = String(localized: "dataIssues.notification.title", bundle: .module)
content.title = String(localized: .dataIssuesNotificationTitle)
content.body = body
content.sound = .default

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling,
components.minute = time.minute

let content = UNMutableNotificationContent()
content.title = String(localized: "reminder.notification.title", bundle: .module)
content.body = String(localized: "reminder.notification.body", bundle: .module)
content.title = String(localized: .reminderNotificationTitle)
content.body = String(localized: .reminderNotificationBody)
content.sound = .default

let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
Expand Down
3 changes: 2 additions & 1 deletion Where/WhereShareExtension/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature
and **LogKit**. Embedded by the **Where** app; shares the
`group.com.stuff.where` App Group entitlement.
- Presentation reuses WhereUI's public `EvidenceKind.symbolName`/`displayName`;
only extension chrome lives in this target's `ShareStrings` + catalog.
only extension chrome lives in this target's own catalog, referenced through
Xcode's generated symbols (`String(localized: .shareTitle)`, `Text(.shareFormNoteHeader)`).
- No test bundle; the write path is covered from **WhereCore** store tests and
the **WhereUI** compose model.

Expand Down
3 changes: 2 additions & 1 deletion Where/WhereShareExtension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ Host app Share sheet
- **`ShareEvidenceView`** is the compose form; kind names/symbols reuse
WhereUI's public `EvidenceKind` presentation helpers so they read identically
to the in-app "Add evidence" sheet. Extension-only chrome resolves through
`ShareStrings` from this target's own catalog.
this target's own String Catalog via Xcode's generated symbols
(`String(localized: .shareTitle)`, `Text(.shareFormNoteHeader)`).

## Why write to the store directly

Expand Down
Loading
Loading