diff --git a/AGENTS.md b/AGENTS.md index 68de8623..b9d3889a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -337,6 +337,25 @@ work end to end without re-asking per comment. not addressed, record it somewhere durable (the module's `TODOs.md` or the review-tracking file) and reply linking where it's tracked. +## Keeping the PR description current + +A PR's title and description are the durable record of what it does — keep them +matching the branch, not just the first commit. + +- **When the PR changes beyond a small bug fix, update the description in the + same turn you push.** New behavior, a new/changed public API or data model, a + migration, a scope change, a follow-up feature, or review fixes that alter the + approach all warrant refreshing the body (and the title if the scope shifted). + A trivial fix — a typo, a one-line bug fix, a test tweak that doesn't change + what the PR is — doesn't. +- **Reflect the end state, not a changelog of the conversation.** Describe what + the PR now does; note notable decisions/trade-offs and testing. Don't leave a + stale body that only describes the initial commit. +- **Preserve human edits.** If someone edited the title/description in the + GitHub UI, fold your update into theirs rather than overwriting — only correct + what's now inaccurate. +- Use the PR tooling when it works; otherwise `gh pr edit --body-file`. + ## Debugging build/test/CI failures **Check `git status` and recent history first — before analyzing the error.** A diff --git a/Where/Tools/upgrade-backup.rb b/Where/Tools/upgrade-backup.rb index 49f95c9f..4b2ed47e 100755 --- a/Where/Tools/upgrade-backup.rb +++ b/Where/Tools/upgrade-backup.rb @@ -21,8 +21,9 @@ # - Dismissals: converts `{ "key": "borderDrift:2026-04-01", ... }` to # `{ "id": "store://issues/borderDrift?day=2026-04-01", ... }`, parsing the # old joined key and recovering any legacy epoch value to a calendar day. -# - Top level: ensures `dismissedIssues` / `trackedRegions` exist and sets -# `formatVersion` to 1 (the reset current version). +# - Top level: ensures `dismissedIssues` / `trackedRegions` exist, synthesizes +# `primaryRegions` from the tracked ids (null appearance, listed order) when +# absent, and sets `formatVersion` to 2 (the current version). # # Idempotent: re-running on an already-upgraded archive is a no-op (it only # touches legacy `date` / `key` fields and unmapped region ids). @@ -39,7 +40,7 @@ require "set" MANIFEST_NAME = "manifest.json" -CURRENT_FORMAT_VERSION = 1 +CURRENT_FORMAT_VERSION = 2 # Former enum-case region ids -> current catalog ids. `canada` / `other` are # unchanged but listed so an already-current id passes through untouched. @@ -171,6 +172,12 @@ def upgrade_manifest(manifest) end manifest["dismissedIssues"] ||= [] manifest["trackedRegions"] ||= [] + # v2 adds `primaryRegions` (each tracked region's picked look + order). + # A pre-v2 archive has no picked looks, so synthesize entries from the + # tracked ids with a null appearance, in their listed order. + manifest["primaryRegions"] ||= manifest["trackedRegions"].each_with_index.map do |id, index| + { "region" => id, "appearance" => nil, "order" => index } + end manifest["formatVersion"] = CURRENT_FORMAT_VERSION warnings.uniq.each { |message| warn "warning: #{message}" } manifest diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 3bcee929..71d806cf 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -34,6 +34,26 @@ internal shape. production `SwiftDataStore` traps otherwise), and each committed transaction pings `changes()` — the single signal readers refresh from. The live `ModelContainer` is surfaced only for the read-only debug inspector. +- **Primary regions *are* the tracked-region set.** The picked primary regions + (`primaryRegions()` / `setPrimaryRegions(_:)`) are the same `SDTrackedRegion` + rows `trackedRegions()` reads — picking scopes GPS attribution *and* carries + each region's `RegionAppearance` (color token / emoji / SF Symbol) + pick + order. `RegionAppearance` is data (WhereCore); the token→`Color` mapping and + option catalogs are presentation (`WhereUI`). +- **Backups mirror the persisted model — keep them lossless.** Any change to + persisted data (a new/changed `SD*` field, or a value type that crosses + `WhereStore`) must be reflected end-to-end in the backup so export/restore + never silently drops it: add it to `BackupArchive`, write it in + `BackupService.makeArchiveFile`, read it back in `BackupCoordinator.importBackup` + for **both** `.replace` and `.merge`, and add a round-trip test + (`BackupServiceTests` for the archive, `BackupCoordinatorTests` for the store + round-trip). The archive is **strict synthesized `Codable`** — no in-code + legacy decode. A shape change **bumps `BackupArchive.currentFormatVersion`** + (`readArchive` rejects any other version) and is handled out of band by + extending [`../Tools/upgrade-backup.rb`](../Tools/upgrade-backup.rb), per the + no-migration-on-read rule below. Example: v2 added `primaryRegions` (per-region + picked appearance + pick order), the tool synthesizes it from the legacy + `trackedRegions` ids, and import restores looks from it. - **A logical day is a `CalendarDay`, not a `Date`.** `CalendarDay` (year-month- day) is the timezone-independent identity of a day, and it is what every *stored user record* and *day comparison* keys on: `DayPresence.day`, diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index e5030d8e..d0192bc8 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -26,9 +26,12 @@ one it belongs to rather than to a god-object: atomic transaction) and `changes()` emits once per commit and on a CloudKit remote import. `SwiftDataStore.make()` is the production, CloudKit-backed implementation; `SwiftDataStore.inMemory()` backs tests and previews. It also - holds the user's **tracked regions** (`trackedRegions()` / - `setTrackedRegion(_:id:)`) — one synced row per region, defaulting to the four - until the user chooses. + holds the user's **tracked / primary regions** (`trackedRegions()` / + `setTrackedRegion(_:id:)`, plus `primaryRegions()` / `setPrimaryRegion(_:id:order:)` + which surface and persist each region's picked `RegionAppearance` — color + token, emoji, SF Symbol — and pick order alongside the synced row) — one row + per region, defaulting to the four until the user chooses in the onboarding / + Settings region picker. - **`RegionAttribution`** — a live `RegionAttributing` built from the tracked regions that rebuilds on `changes()` (a local edit or a remote import), so the app + App Intents process attribute against the same synced set. Assemble diff --git a/Where/WhereCore/Sources/Backup/BackupArchive.swift b/Where/WhereCore/Sources/Backup/BackupArchive.swift index 63f3ec89..db37184e 100644 --- a/Where/WhereCore/Sources/Backup/BackupArchive.swift +++ b/Where/WhereCore/Sources/Backup/BackupArchive.swift @@ -15,7 +15,13 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// readers can't understand, so an importer can refuse a file it doesn't /// know how to read instead of silently dropping data (see /// `BackupService.readArchive`, which rejects any other version). - public static let currentFormatVersion = 1 + /// + /// v2 adds `primaryRegions` (each tracked region's picked appearance + pick + /// order). There's no in-app decode fallback for a pre-v2 archive — it's + /// reshaped out of band by `Tools/upgrade-backup.rb` (which synthesizes + /// `primaryRegions` from `trackedRegions`), matching the module's + /// no-migration-on-read rule (see `AGENTS.md`). + public static let currentFormatVersion = 2 public let formatVersion: Int public let exportedAt: Date @@ -25,9 +31,15 @@ public struct BackupArchive: Codable, Sendable, Hashable { /// Data-resolution dismissals (issue id + when dismissed), so a restore /// keeps issues the user already dismissed dismissed. public let dismissedIssues: [DismissedIssue] - /// The user's tracked regions at export time, so a restore carries the - /// region selection like any other data. + /// The user's tracked regions at export time (region ids only), so a restore + /// carries the region selection. Retained alongside ``primaryRegions`` as the + /// bare-id list `upgrade-backup.rb` and the import summary count read. public let trackedRegions: [Region] + /// The user's primary regions at export time, each with its picked + /// ``RegionAppearance`` (color / emoji / icon) and pick order, so a restore + /// brings back the *look*, not just the region set. Import restores from + /// this; `trackedRegions` is the derived id list. + public let primaryRegions: [PrimaryRegion] /// One entry per evidence record that has blob bytes in the archive. /// Evidence without bytes simply has no entry here. public let assets: [BackupAssetEntry] @@ -40,6 +52,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { manualDays: [DayPresence], dismissedIssues: [DismissedIssue], trackedRegions: [Region], + primaryRegions: [PrimaryRegion], assets: [BackupAssetEntry], ) { self.formatVersion = formatVersion @@ -49,6 +62,7 @@ public struct BackupArchive: Codable, Sendable, Hashable { self.manualDays = manualDays self.dismissedIssues = dismissedIssues self.trackedRegions = trackedRegions + self.primaryRegions = primaryRegions self.assets = assets } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index fc2034be..43885510 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -102,9 +102,11 @@ public actor BackupCoordinator { let evidence = try await store.allEvidence() let manualDays = try await store.allManualDays() let dismissedIssues = try await store.allDismissedIssues() - // The resolved tracked set (the four when the user hasn't chosen yet), - // in canonical order so the archive is stable. - let trackedRegions = try await Region.inCanonicalOrder(store.trackedRegions()) + // The user's primary regions with their picked looks + order (the + // resolved default set when they haven't chosen yet). `trackedRegions` + // carries the bare ids alongside it for older readers. + let primaryRegions = try await store.primaryRegions() + let trackedRegions = primaryRegions.map(\.region) var blobs: [UUID: Data] = [:] var lastPercent = -1 for (index, item) in evidence.enumerated() { @@ -125,6 +127,7 @@ public actor BackupCoordinator { manualDays: manualDays, dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, + primaryRegions: primaryRegions, blobs: blobs, ) }.value @@ -221,20 +224,21 @@ public actor BackupCoordinator { try await store.restoreDismissedIssue(dismissal) report() } - // Tracked regions round-trip like any other data. On `.replace` the - // store was cleared above, so write the archive's set exactly; on - // `.merge` union it into the current set (reading the *resolved* - // current set first so a device on the implicit default four doesn't - // collapse to just the imported regions). A handful of rows, so - // they're not folded into the progress total. - let regionsToWrite: Set = if strategy == .merge { - try await store.trackedRegions().union(archive.trackedRegions) + // Primary regions (with their picked looks) round-trip like any + // other data. On `.replace` the store was cleared above, so write + // the archive's set exactly; on `.merge` union it into the current + // set (reading the *resolved* current set first so a device on the + // implicit default four doesn't collapse to just the imported ones), + // with the archive's appearance winning on overlap. `setPrimaryRegions` + // is a whole-set replace, so a merge builds the full merged list. A + // handful of rows, so they're not folded into the progress total. + let archivePrimary = archive.primaryRegions + let regionsToWrite: [PrimaryRegion] = if strategy == .merge { + try await Self.merge(archivePrimary, into: store.primaryRegions()) } else { - Set(archive.trackedRegions) - } - for region in regionsToWrite { - try await store.setTrackedRegion(true, id: region.rawValue) + archivePrimary } + try await store.setPrimaryRegions(regionsToWrite) } // An import rewrites day data, so the badge / notification / widget // reconcile a day change runs has to follow it — these headless @@ -248,7 +252,35 @@ public actor BackupCoordinator { evidenceCount: archive.evidence.count, manualDayCount: archive.manualDays.count, dismissedIssueCount: archive.dismissedIssues.count, - trackedRegionCount: archive.trackedRegions.count, + trackedRegionCount: archive.primaryRegions.count, ) } + + /// Union `archive` primary regions into `current` for a `.merge` import: + /// current regions keep their order and come first, archive-only regions are + /// appended, and the archive's picked appearance wins on overlap (a `nil` + /// archive look never clobbers an existing customized one). Reindexed densely + /// for `setPrimaryRegions`. + private static func merge( + _ archive: [PrimaryRegion], + into current: [PrimaryRegion], + ) -> [PrimaryRegion] { + var appearances: [Region: RegionAppearance] = [:] + var order: [Region] = [] + var seen: Set = [] + func add(_ region: Region) { + if seen.insert(region).inserted { order.append(region) } + } + for entry in current { + add(entry.region) + if let appearance = entry.appearance { appearances[entry.region] = appearance } + } + for entry in archive { + add(entry.region) + if let appearance = entry.appearance { appearances[entry.region] = appearance } + } + return order.enumerated().map { index, region in + PrimaryRegion(region: region, appearance: appearances[region], order: index) + } + } } diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 0ea04ede..015d806d 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -93,6 +93,7 @@ public struct BackupService: Sendable { manualDays: [DayPresence], dismissedIssues: [DismissedIssue] = [], trackedRegions: [Region] = [], + primaryRegions: [PrimaryRegion] = [], blobs: [UUID: Data], exportedAt: Date = Date(), archiveName: String? = nil, @@ -124,6 +125,7 @@ public struct BackupService: Sendable { manualDays: manualDays, dismissedIssues: dismissedIssues, trackedRegions: trackedRegions, + primaryRegions: primaryRegions, assets: assetEntries, ) let manifestData = try Self.makeEncoder().encode(archive) diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index adab12dd..a6f4254b 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -761,18 +761,94 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { // TODO: Untracking deletes the row, which drops the region from the // attributor's load set — so re-aggregating a past year would // re-attribute that region's GPS days to `.other` (manual days, - // stored as region sets, are unaffected). When the onboarding / - // region picker lands, switch to a soft-delete (mark the row - // inactive, never delete) and have the attributor load every - // ever-tracked region so past reports stay stable, while "active" - // drives the UI/primary set. Not user-reachable yet — nothing calls - // `setTrackedRegion(false)` outside tests. + // stored as region sets, are unaffected). Now user-reachable (the + // onboarding picker and the Settings region editor remove picks via + // `removePrimaryRegion` → here), so switching to a soft-delete (mark + // the row inactive, never delete) and having the attributor load + // every ever-tracked region — so past reports stay stable while + // "active" drives the UI/primary set — is worth doing. for record in existing { context.delete(record) } } } + public func primaryRegions() async throws -> [PrimaryRegion] { + let context = readContext() + var descriptor = FetchDescriptor() + descriptor.includePendingChanges = true + let rows = try context.fetch(descriptor) + // No rows means the user hasn't chosen yet — mirror `trackedRegions()`'s + // default fallback so the picker/customization UI opens on the + // out-of-the-box set rather than empty. + guard !rows.isEmpty else { + return Region.inCanonicalOrder(Self.defaultTrackedRegions) + .enumerated() + .map { PrimaryRegion(region: $1, appearance: nil, order: $0) } + } + var resolved: [PrimaryRegion] = [] + var unknown: [String] = [] + // De-dupe by id (CloudKit can't enforce uniqueness): first row per id + // wins, preferring one that carries an appearance/order. + var seen: Set = [] + let sorted = rows.sorted { lhs, rhs in + let l = lhs.orderIndex ?? Int.max + let r = rhs.orderIndex ?? Int.max + if l != r { return l < r } + return (lhs.regionID ?? "") < (rhs.regionID ?? "") + } + for row in sorted { + guard let id = row.regionID else { continue } + guard let region = Region(rawValue: id) else { + unknown.append(id) + continue + } + guard seen.insert(id).inserted else { continue } + resolved.append(PrimaryRegion( + region: region, + appearance: row.appearanceValue, + order: row.orderIndex ?? resolved.count, + )) + } + if !unknown.isEmpty { + Self.logger.warning( + "Ignored \(unknown.count) unknown primary-region id(s): \(unknown.sorted().joined(separator: ", "))", + ) + } + return resolved + } + + public func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws { + let context = mutationContext() + let desiredIDs = Set(regions.map(\.region.rawValue)) + // Delete every tracked row not in the desired set (and any row with a + // nil id, which we can't resolve) — removals happen by omission. + for row in try context.fetch(FetchDescriptor()) { + if let id = row.regionID, desiredIDs.contains(id) { continue } + context.delete(row) + } + // Upsert each desired region's row (membership + appearance + order), + // collapsing any accidental duplicate rows to one (CloudKit can't + // enforce uniqueness). + for entry in regions { + let id = entry.region.rawValue + let existing = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.regionID == id }, + )) + let row: SDTrackedRegion + if let first = existing.first { + for extra in existing.dropFirst() { + context.delete(extra) + } + row = first + } else { + row = SDTrackedRegion(regionID: id) + context.insert(row) + } + row.apply(appearance: entry.appearance, order: entry.order) + } + } + private static func logFault(forCorrupt _: Record) { logger.fault( "Dropped corrupt SwiftData record of type \(String(describing: Record.self))", @@ -1012,13 +1088,40 @@ final class SDDismissedIssue { /// and add/add of the same region collapses to one on read (`trackedRegions()` /// returns a `Set`). `regionID` is `Region.rawValue`; optional per the CloudKit /// mirror's requirement. +/// +/// The `colorRaw` / `emoji` / `symbolName` / `orderIndex` fields carry the +/// region's user-picked ``RegionAppearance`` and pick order. All optional — +/// both because CloudKit requires it and because a row can be tracked without a +/// chosen look yet (the default set, or a legacy row); a resolved appearance +/// needs all three style fields present. @Model final class SDTrackedRegion { var regionID: String? + var colorRaw: String? + var emoji: String? + var symbolName: String? + var orderIndex: Int? init() {} init(regionID: String) { self.regionID = regionID } + + /// The stored appearance, or `nil` when the row has no complete look yet. + var appearanceValue: RegionAppearance? { + guard let colorRaw, let color = RegionColorToken(rawValue: colorRaw), + let emoji, let symbolName + else { return nil } + return RegionAppearance(color: color, emoji: emoji, symbolName: symbolName) + } + + /// Overwrite the stored appearance (clearing the style fields when `nil`) + /// and pick order. + func apply(appearance: RegionAppearance?, order: Int?) { + colorRaw = appearance?.color.rawValue + emoji = appearance?.emoji + symbolName = appearance?.symbolName + orderIndex = order + } } diff --git a/Where/WhereCore/Sources/Persistence/WhereStore.swift b/Where/WhereCore/Sources/Persistence/WhereStore.swift index fc45ba7e..f7f1300c 100644 --- a/Where/WhereCore/Sources/Persistence/WhereStore.swift +++ b/Where/WhereCore/Sources/Persistence/WhereStore.swift @@ -105,10 +105,26 @@ public protocol WhereStore: Sendable { /// ``WhereStore/defaultTrackedRegions``. func trackedRegions() async throws -> Set + /// The user's tracked regions with their picked appearance and pick order — + /// the same rows ``trackedRegions()`` reads, surfaced as ordered + /// ``PrimaryRegion`` values for the picker/customization UI. Ordered by the + /// stored pick order (rows without one sort last, then by region id). When + /// the user hasn't chosen any yet, mirrors ``WhereStore/defaultTrackedRegions`` + /// (in canonical order, with no stored appearance). + func primaryRegions() async throws -> [PrimaryRegion] + /// Add (`tracked == true`) or remove (`false`) a single tracked region by /// its `Region.rawValue`. Per-region so two devices adding different regions /// both survive a sync. Must run inside `perform { ... }`. func setTrackedRegion(_ tracked: Bool, id: String) async throws + + /// Replace the entire primary set with `regions`: upsert a row per entry + /// (storing its `appearance` — cleared when `nil` — and pick `order`) and + /// delete every tracked row not in `regions`. The picker/customization + /// commit path — the ordered list fully describes the primary (tracked) set, + /// so removals happen by omission rather than a separate call. Must run + /// inside `perform { ... }`. + func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws } extension WhereStore { @@ -125,7 +141,20 @@ extension WhereStore { Self.defaultTrackedRegions } + /// Default: derive ordered primary regions from ``trackedRegions()`` with no + /// stored appearance. `SwiftDataStore` overrides this to read the persisted + /// appearance and pick order; test fakes inherit this catalog-ordered view. + public func primaryRegions() async throws -> [PrimaryRegion] { + try await Region.inCanonicalOrder(trackedRegions()) + .enumerated() + .map { PrimaryRegion(region: $1, appearance: nil, order: $0) } + } + /// Default: a no-op. `SwiftDataStore` overrides this to persist rows; test /// fakes that don't exercise tracked-region persistence inherit the no-op. public func setTrackedRegion(_: Bool, id _: String) async throws {} + + /// Default: a no-op. `SwiftDataStore` overrides this to replace the persisted + /// rows; test fakes that don't exercise persistence inherit the no-op. + public func setPrimaryRegions(_: [PrimaryRegion]) async throws {} } diff --git a/Where/WhereCore/Sources/Regions/PrimaryRegion.swift b/Where/WhereCore/Sources/Regions/PrimaryRegion.swift new file mode 100644 index 00000000..aec047e9 --- /dev/null +++ b/Where/WhereCore/Sources/Regions/PrimaryRegion.swift @@ -0,0 +1,23 @@ +import Foundation +import RegionKit + +/// A region the user has chosen as one of their primary places, paired with its +/// customized ``RegionAppearance`` and the position (``order``) it was picked +/// in. The primary set *is* the tracked-region set — the regions the app loads +/// geometry for and attributes GPS against — so picking these both scopes +/// attribution and drives per-region styling. +/// +/// `appearance` is optional: a region can be tracked without a stored look yet +/// (the out-of-the-box default set, or a legacy row), in which case the +/// presentation layer falls back to a deterministic default style. +public struct PrimaryRegion: Hashable, Sendable, Codable { + public let region: Region + public let appearance: RegionAppearance? + public let order: Int + + public init(region: Region, appearance: RegionAppearance?, order: Int) { + self.region = region + self.appearance = appearance + self.order = order + } +} diff --git a/Where/WhereCore/Sources/Regions/RegionAppearance.swift b/Where/WhereCore/Sources/Regions/RegionAppearance.swift new file mode 100644 index 00000000..9e1913c9 --- /dev/null +++ b/Where/WhereCore/Sources/Regions/RegionAppearance.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A stable, storable identifier for a region's accent color. Persisted (as its +/// `rawValue`) rather than a platform `Color` so the choice survives across +/// processes, backups, and the CloudKit mirror; the presentation layer +/// (`WhereUI.RegionStyle`) maps each token to a concrete SwiftUI color. +/// +/// The cases mirror the historical `RegionStyle` default palette so an +/// unpicked region and a picked-then-matched one render identically. +public enum RegionColorToken: String, CaseIterable, Sendable, Codable, Hashable { + case orange + case indigo + case red + case blue + case teal + case green + case mint + case cyan + case purple + case pink + case brown +} + +/// The user-chosen look for a region: an accent color token, an emoji, and an +/// SF Symbol name. Pure data — the concrete color and any option catalogs live +/// in the presentation layer. Persisted alongside the region's tracked-region +/// row so a customized region keeps its look across devices and reinstalls. +public struct RegionAppearance: Hashable, Sendable, Codable { + public var color: RegionColorToken + public var emoji: String + public var symbolName: String + + public init(color: RegionColorToken, emoji: String, symbolName: String) { + self.color = color + self.emoji = emoji + self.symbolName = symbolName + } +} diff --git a/Where/WhereCore/Sources/WhereServices.swift b/Where/WhereCore/Sources/WhereServices.swift index 16d4b520..ad403ffc 100644 --- a/Where/WhereCore/Sources/WhereServices.swift +++ b/Where/WhereCore/Sources/WhereServices.swift @@ -257,6 +257,23 @@ public struct WhereServices: Sendable { try await store.trackedRegions() } + /// The user's primary (tracked) regions with their picked appearance and + /// order — the store's ``PrimaryRegion`` rows. Drives the region picker / + /// customization UI and seeds the presentation layer's region styling. + public func primaryRegions() async throws -> [PrimaryRegion] { + try await store.primaryRegions() + } + + /// Replace the user's primary (tracked) regions with `regions` — the + /// picker/customization commit path. One `perform`, so the whole change + /// (upserts + removals-by-omission) is a single atomic transaction that + /// pings `changes()` once. + public func setPrimaryRegions(_ regions: [PrimaryRegion]) async throws { + try await store.perform { + try await store.setPrimaryRegions(regions) + } + } + /// Return the services to a clean slate for the app's "erase all data & /// reset" teardown: quiesce GPS ingestion (stop monitoring, refuse further /// samples, await any in-flight write, and drop the retry backlog) so diff --git a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift index d89262c2..6879639f 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetDataReader.swift @@ -19,12 +19,42 @@ public struct WidgetSnapshot: Hashable, Sendable, Codable { /// Day counts per region for `year` (a `YearReport.totals`). A day in /// two regions counts once for each. public let totals: [Region: Int] + /// The user's picked appearances for their primary regions, carried across + /// the App Group so the widget process can render each region's chosen + /// color/emoji/icon (it has no store access and no `WhereSession`, so it + /// seeds its `RegionStyleResolver` from this). Empty for regions the user + /// hasn't customized (and for snapshots written before this field existed) — + /// those fall back to the default look. + public let appearances: [Region: RegionAppearance] - public init(day: Date, year: Int, dayRegions: Set, totals: [Region: Int]) { + public init( + day: Date, + year: Int, + dayRegions: Set, + totals: [Region: Int], + appearances: [Region: RegionAppearance] = [:], + ) { self.day = day self.year = year self.dayRegions = dayRegions self.totals = totals + self.appearances = appearances + } + + private enum CodingKeys: String, CodingKey { + case day, year, dayRegions, totals, appearances + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + day = try container.decode(Date.self, forKey: .day) + year = try container.decode(Int.self, forKey: .year) + dayRegions = try container.decode(Set.self, forKey: .dayRegions) + totals = try container.decode([Region: Int].self, forKey: .totals) + // Additive: snapshots written before `appearances` existed decode to an + // empty map rather than failing (the widget then uses default looks). + appearances = try container + .decodeIfPresent([Region: RegionAppearance].self, forKey: .appearances) ?? [:] } } @@ -70,11 +100,16 @@ public struct WidgetDataReader: Sendable { let dayRegions = report.days .first { $0.day == calendarDay }? .regions ?? [] + var appearances: [Region: RegionAppearance] = [:] + for primary in try await store.primaryRegions() { + if let appearance = primary.appearance { appearances[primary.region] = appearance } + } return WidgetSnapshot( day: startOfDay, year: year, dayRegions: dayRegions, totals: report.totals, + appearances: appearances, ) } } diff --git a/Where/WhereCore/Tests/BackupCoordinatorTests.swift b/Where/WhereCore/Tests/BackupCoordinatorTests.swift index 73e38df3..1c65292f 100644 --- a/Where/WhereCore/Tests/BackupCoordinatorTests.swift +++ b/Where/WhereCore/Tests/BackupCoordinatorTests.swift @@ -154,6 +154,25 @@ struct BackupCoordinatorTests { #expect(try await destination.store.trackedRegions() == [.california, texas]) } + @Test func importRestoresPickedRegionAppearance() async throws { + let source = try Self.makeHarness() + let caLook = RegionAppearance(color: .orange, emoji: "🌴", symbolName: "sun.max.fill") + try await source.store.perform { + try await source.store.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: caLook, order: 0), + ]) + } + let url = try await source.coordinator.exportBackup() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let destination = try Self.makeHarness() + _ = try await destination.coordinator.importBackup(from: url, strategy: .replace) + + let restored = try await destination.store.primaryRegions() + #expect(restored.map(\.region) == [.california]) + #expect(restored.first?.appearance == caLook) + } + @Test func mergeImportUnionsTrackedRegionsWithTheExisting() async throws { let source = try Self.makeHarness() let texas = try #require(Region(rawValue: "us-TX")) diff --git a/Where/WhereCore/Tests/BackupServiceTests.swift b/Where/WhereCore/Tests/BackupServiceTests.swift index 7b5a1dbe..a0757de4 100644 --- a/Where/WhereCore/Tests/BackupServiceTests.swift +++ b/Where/WhereCore/Tests/BackupServiceTests.swift @@ -173,6 +173,38 @@ struct BackupServiceTests { #expect(result.archive.trackedRegions == [.california, texas]) } + @Test func primaryRegionAppearanceSurvivesArchiveRoundTrip() throws { + let service = BackupService() + let texas = try #require(Region(rawValue: "us-TX")) + let primary = [ + PrimaryRegion( + region: .california, + appearance: RegionAppearance( + color: .orange, + emoji: "🌴", + symbolName: "sun.max.fill", + ), + order: 0, + ), + PrimaryRegion(region: texas, appearance: nil, order: 1), + ] + let url = try service.makeArchiveFile( + samples: [], + evidence: [], + manualDays: [], + trackedRegions: primary.map(\.region), + primaryRegions: primary, + blobs: [:], + exportedAt: Self.exportDate, + ) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let result = try service.readArchive(at: url) + #expect(result.archive.primaryRegions == primary) + // The bare-id list is written alongside for the summary count. + #expect(result.archive.trackedRegions == [.california, texas]) + } + @Test func auditManualDaySurvivesArchiveRoundTrip() throws { let service = BackupService() let manualDays = [ @@ -214,6 +246,18 @@ struct BackupServiceTests { manualDays: Self.manualDayFixtures(), dismissedIssues: Self.dismissedIssueFixtures(), trackedRegions: [.california, .newYork], + primaryRegions: [ + PrimaryRegion( + region: .california, + appearance: RegionAppearance( + color: .orange, + emoji: "🌴", + symbolName: "sun.max.fill", + ), + order: 0, + ), + PrimaryRegion(region: .newYork, appearance: nil, order: 1), + ], assets: [BackupAssetEntry( evidenceId: Self.evidenceWithBlobId, filename: "assets/\(Self.evidenceWithBlobId.uuidString)", @@ -229,7 +273,7 @@ struct BackupServiceTests { let decoded = try decoder.decode(BackupArchive.self, from: data) #expect(decoded == archive) - #expect(decoded.formatVersion == 1) + #expect(decoded.formatVersion == 2) } @Test func readingAFileThatIsNotAZipThrows() throws { diff --git a/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift new file mode 100644 index 00000000..982a4f4d --- /dev/null +++ b/Where/WhereCore/Tests/PrimaryRegionStoreTests.swift @@ -0,0 +1,95 @@ +import Foundation +import RegionKit +import Testing +@testable import WhereCore + +/// The store's primary-region view: the same tracked-region rows, surfaced with +/// their picked ``RegionAppearance`` and pick order for the picker/customization +/// UI. +struct PrimaryRegionStoreTests { + private func appearance(_ color: RegionColorToken, _ emoji: String, _ symbol: String) + -> RegionAppearance + { + RegionAppearance(color: color, emoji: emoji, symbolName: symbol) + } + + @Test func defaultsMirrorTheTrackedFallbackWhenNoRows() async throws { + let store = try SwiftDataStore.inMemory() + let primary = try await store.primaryRegions() + #expect(Set(primary.map(\.region)) == SwiftDataStore.defaultTrackedRegions) + #expect(primary.allSatisfy { $0.appearance == nil }) + // Order is dense and canonical (0.. PrimaryRegion + { + PrimaryRegion(region: region, appearance: appearance, order: order) + } + + @Test func replacePersistsAppearanceAndOrder() async throws { + let store = try SwiftDataStore.inMemory() + let texas = try #require(Region(rawValue: "us-TX")) + let caLook = appearance(.orange, "🌴", "sun.max.fill") + let txLook = appearance(.red, "🤠", "star.fill") + try await store.perform { + try await store.setPrimaryRegions([ + pick(.california, caLook, 0), + pick(texas, txLook, 1), + ]) + } + + let primary = try await store.primaryRegions() + #expect(primary.map(\.region) == [.california, texas]) + #expect(primary.map(\.appearance) == [caLook, txLook]) + // The primary set is the tracked set. + #expect(try await store.trackedRegions() == [.california, texas]) + } + + @Test func replaceOverwritesAnExistingRowLook() async throws { + let store = try SwiftDataStore.inMemory() + let first = appearance(.orange, "🌴", "sun.max.fill") + let second = appearance(.indigo, "🌉", "building.2.fill") + try await store.perform { + try await store.setPrimaryRegions([pick(.california, first, 0)]) + } + try await store.perform { + try await store.setPrimaryRegions([pick(.california, second, 3)]) + } + let primary = try await store.primaryRegions() + #expect(primary.count == 1) + #expect(primary.first?.appearance == second) + #expect(primary.first?.order == 3) + } + + @Test func replaceRemovesOmittedRegions() async throws { + let store = try SwiftDataStore.inMemory() + let texas = try #require(Region(rawValue: "us-TX")) + try await store.perform { + try await store.setPrimaryRegions([ + pick(.california, appearance(.orange, "🌴", "sun.max.fill"), 0), + pick(texas, appearance(.red, "🤠", "star.fill"), 1), + ]) + } + // Re-committing without California removes it by omission. + try await store.perform { + try await store.setPrimaryRegions([ + pick(texas, appearance(.red, "🤠", "star.fill"), 0), + ]) + } + #expect(try await store.primaryRegions().map(\.region) == [texas]) + #expect(try await store.trackedRegions() == [texas]) + } + + @Test func trackedWithoutAppearanceResolvesToNilLook() async throws { + let store = try SwiftDataStore.inMemory() + try await store.perform { + try await store.setPrimaryRegions([pick(.california, nil, 0)]) + } + let primary = try await store.primaryRegions() + #expect(primary.map(\.region) == [.california]) + #expect(primary.first?.appearance == nil) + } +} diff --git a/Where/WhereCore/Tests/RegionAppearanceTests.swift b/Where/WhereCore/Tests/RegionAppearanceTests.swift new file mode 100644 index 00000000..c57e4c19 --- /dev/null +++ b/Where/WhereCore/Tests/RegionAppearanceTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import WhereCore + +/// `RegionAppearance` / `RegionColorToken` are persisted (the token by +/// `rawValue`), so their raw values and Codable shape must stay stable. +struct RegionAppearanceTests { + @Test func colorTokenRawValuesAreStable() { + #expect(RegionColorToken.allCases.map(\.rawValue) == [ + "orange", + "indigo", + "red", + "blue", + "teal", + "green", + "mint", + "cyan", + "purple", + "pink", + "brown", + ]) + } + + @Test func appearanceCodableRoundTrips() throws { + let appearance = RegionAppearance(color: .teal, emoji: "🌴", symbolName: "sun.max.fill") + let data = try JSONEncoder().encode(appearance) + let decoded = try JSONDecoder().decode(RegionAppearance.self, from: data) + #expect(decoded == appearance) + } +} diff --git a/Where/WhereCore/Tests/WidgetDataReaderTests.swift b/Where/WhereCore/Tests/WidgetDataReaderTests.swift index 50a20713..b27ff46b 100644 --- a/Where/WhereCore/Tests/WidgetDataReaderTests.swift +++ b/Where/WhereCore/Tests/WidgetDataReaderTests.swift @@ -36,6 +36,39 @@ struct WidgetDataReaderTests { #expect(snapshot.year == 2026) #expect(snapshot.dayRegions.isEmpty) #expect(snapshot.totals.isEmpty) + #expect(snapshot.appearances.isEmpty) + } + + @Test func snapshotCarriesPickedRegionAppearances() async throws { + let (reader, store) = try Self.makeReader() + let caLook = RegionAppearance(color: .orange, emoji: "🌴", symbolName: "sun.max.fill") + try await store.perform { + try await store.setPrimaryRegions([ + PrimaryRegion(region: .california, appearance: caLook, order: 0), + // A tracked region with no picked look contributes no appearance. + PrimaryRegion(region: .newYork, appearance: nil, order: 1), + ]) + } + + let snapshot = try await reader + .snapshot(asOf: WhereCoreTestSupport.iso("2026-03-15T12:00:00-07:00")) + + #expect(snapshot.appearances == [.california: caLook]) + } + + @Test func snapshotAppearancesSurviveCodableRoundTrip() throws { + let look = RegionAppearance(color: .indigo, emoji: "🗽", symbolName: "building.2.fill") + let snapshot = WidgetSnapshot( + day: WhereCoreTestSupport.iso("2026-03-15T00:00:00-07:00"), + year: 2026, + dayRegions: [.newYork], + totals: [.newYork: 3], + appearances: [.newYork: look], + ) + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(WidgetSnapshot.self, from: data) + #expect(decoded == snapshot) + #expect(decoded.appearances == [.newYork: look]) } @Test func samplesAndManualDaysRollUpLikeTheYearReport() async throws { diff --git a/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift b/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift index f898d918..310ec064 100644 --- a/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift +++ b/Where/WhereIntents/Sources/DaysInRegionSnippetIntent.swift @@ -35,6 +35,9 @@ public struct DaysInRegionSnippetIntent: SnippetIntent { year: resolvedYear, dayCount: count, ) + // Seed the region look from the user's picks so the snippet renders the + // chosen color/emoji/icon (the intent process has no app view root). + let regionStyles = try await RegionStyleResolver(primaryRegions: services.primaryRegions()) // "Log today here" logs into the *current* year, so it can only change // this card's count when the card is showing the current year. Omit it // otherwise, rather than offer a button that appears to do nothing. @@ -43,6 +46,7 @@ public struct DaysInRegionSnippetIntent: SnippetIntent { snapshot: snapshot, region: region, canLogToday: isCurrentYear(resolvedYear), + regionStyles: regionStyles, ), ) } @@ -56,10 +60,11 @@ struct DaysInRegionInteractiveSnippet: View { let snapshot: DaysInRegionSnapshot let region: RegionEntity let canLogToday: Bool + let regionStyles: RegionStyleResolver var body: some View { content - .whereBroadwayRoot() + .whereBroadwayRoot(regionStyles: regionStyles) } @ViewBuilder private var content: some View { @@ -76,7 +81,7 @@ struct DaysInRegionInteractiveSnippet: View { .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) - .tint(snapshot.region.style.tint) + .tint(regionStyles.style(for: snapshot.region).tint) } } else { DaysInRegionSnippetView(snapshot: snapshot) diff --git a/Where/WhereIntents/Sources/Resources/Localizable.xcstrings b/Where/WhereIntents/Sources/Resources/Localizable.xcstrings index 8a52928a..ee0ca51a 100644 --- a/Where/WhereIntents/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereIntents/Sources/Resources/Localizable.xcstrings @@ -1,6 +1,10 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { + "comment" : "A spoken dialog response for the \"Count days in a region\" intent. The argument is the region name, the second is the number of days, and the third is the year.", + "isCommentAutoGenerated" : true + }, "audit.note.siri" : { "comment" : "Note stored on a manual day entry made through an intent.", "localizations" : { @@ -12,6 +16,26 @@ } } }, + "Backfill a range of days with the regions you were in." : { + "comment" : "Description of the Log Trip intent.", + "isCommentAutoGenerated" : true + }, + "Count Days in a Region" : { + "comment" : "Intent name for the \"Count Days in a Region\" action.", + "isCommentAutoGenerated" : true + }, + "Date" : { + "comment" : "Label for the date parameter in the RegionOnDateIntent.", + "isCommentAutoGenerated" : true + }, + "Day" : { + "comment" : "The name of the parameter for the day of the week.", + "isCommentAutoGenerated" : true + }, + "Days in a Region" : { + "comment" : "Title of the Days in a Region snippet.", + "isCommentAutoGenerated" : true + }, "dialog.daysInRegion.none" : { "comment" : "Spoken result when a region has no logged days this year. %1$@ region name, %2$@ year.", "localizations" : { @@ -98,75 +122,75 @@ } } }, - "dialog.regionOnDate.none" : { - "comment" : "Spoken result when a date has nothing logged. %1$@ formatted date.", + "dialog.recentActivity.empty" : { + "comment" : "Spoken result when a window has no tracked activity. %1$@ window phrase.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nothing is logged for %1$@." + "value" : "Nothing was tracked in %1$@." } } } }, - "dialog.regionOnDate.some" : { - "comment" : "Spoken result naming the regions a date counted for. %1$@ date, %2$@ region list.", + "dialog.recentActivity.unavailable.appleIntelligenceNotEnabled" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "On %1$@ you were in %2$@." + "value" : "Turn on Apple Intelligence in Settings to summarize where you've been." } } } }, - "dialog.recentActivity.empty" : { - "comment" : "Spoken result when a window has no tracked activity. %1$@ window phrase.", + "dialog.recentActivity.unavailable.deviceNotEligible" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nothing was tracked in %1$@." + "value" : "This device doesn't support on-device summaries." } } } }, - "dialog.recentActivity.unavailable.appleIntelligenceNotEnabled" : { + "dialog.recentActivity.unavailable.modelNotReady" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Turn on Apple Intelligence in Settings to summarize where you've been." + "value" : "The on-device model is still getting ready. Try again shortly." } } } }, - "dialog.recentActivity.unavailable.deviceNotEligible" : { + "dialog.recentActivity.unavailable.unknown" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "This device doesn't support on-device summaries." + "value" : "On-device summaries aren't available right now." } } } }, - "dialog.recentActivity.unavailable.modelNotReady" : { + "dialog.regionOnDate.none" : { + "comment" : "Spoken result when a date has nothing logged. %1$@ formatted date.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The on-device model is still getting ready. Try again shortly." + "value" : "Nothing is logged for %1$@." } } } }, - "dialog.recentActivity.unavailable.unknown" : { + "dialog.regionOnDate.some" : { + "comment" : "Spoken result naming the regions a date counted for. %1$@ date, %2$@ region list.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "On-device summaries aren't available right now." + "value" : "On %1$@ you were in %2$@." } } } @@ -232,6 +256,61 @@ } } }, + "End Date" : { + "comment" : "Label for the end date parameter in the Log a Trip intent.", + "isCommentAutoGenerated" : true + }, + "Find out how many days you've spent in a region so far this year." : { + + }, + "Find Regions on a Date" : { + "comment" : "Title of the intent.", + "isCommentAutoGenerated" : true + }, + "Get an on-device summary of where you've been over a recent window." : { + "comment" : "Description of the intent to get an on-device summary of where the user has been over a recent window.", + "isCommentAutoGenerated" : true + }, + "Last 24 Hours" : { + "comment" : "Display name for the \"Last 24 Hours\" option in the Siri-resolved menu.", + "isCommentAutoGenerated" : true + }, + "Log a Day's Regions" : { + "comment" : "Intent title.", + "isCommentAutoGenerated" : true + }, + "Log a Trip" : { + + }, + "Look up which regions a particular day counted for." : { + + }, + "Past Month" : { + + }, + "Past Week" : { + "comment" : "Displayed title for the \"Past Week\" option in the Siri-resolved menu.", + "isCommentAutoGenerated" : true + }, + "Record which regions you were in on a day." : { + + }, + "Region" : { + "comment" : "The parameter name for the region parameter in the DaysInRegionIntent.", + "isCommentAutoGenerated" : true + }, + "Regions" : { + "comment" : "The parameter title for the regions parameter in the Log Day intent.", + "isCommentAutoGenerated" : true + }, + "See which regions today counts for so far." : { + "comment" : "Description of the \"Show Today's Regions\" intent.", + "isCommentAutoGenerated" : true + }, + "Show Today's Regions" : { + "comment" : "Title of the \"Where am I today?\" intent.", + "isCommentAutoGenerated" : true + }, "snippet.logTodayHere" : { "comment" : "Button in the day-count snippet that logs today for the shown region.", "localizations" : { @@ -242,7 +321,24 @@ } } } + }, + "Start Date" : { + + }, + "Summarize Recent Activity" : { + "comment" : "Intent to summarize recent activity.", + "isCommentAutoGenerated" : true + }, + "Time Range" : { + + }, + "Year" : { + + }, + "Year So Far" : { + "comment" : "Display name for the \"Year So Far\" option in the", + "isCommentAutoGenerated" : true } }, - "version" : "1.0" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Where/WhereShareExtension/Resources/Localizable.xcstrings b/Where/WhereShareExtension/Resources/Localizable.xcstrings index 045b4e95..4dc9a857 100644 --- a/Where/WhereShareExtension/Resources/Localizable.xcstrings +++ b/Where/WhereShareExtension/Resources/Localizable.xcstrings @@ -3,6 +3,7 @@ "strings" : { "share.attachment.fallbackName" : { "comment" : "Stand-in name for a shared attachment the provider gave no filename for.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -14,6 +15,7 @@ }, "share.attachment.header" : { "comment" : "Section header above the shared attachment summary (one item).", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -25,6 +27,7 @@ }, "share.attachment.headerPlural" : { "comment" : "Section header above the shared attachments (more than one item).", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -36,6 +39,7 @@ }, "share.attachment.none" : { "comment" : "Shown when the share carried nothing loadable, so only metadata is saved.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -47,6 +51,7 @@ }, "share.cancel" : { "comment" : "Cancel button dismissing the share sheet without saving.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -58,6 +63,7 @@ }, "share.form.date" : { "comment" : "Date picker label for when the evidence was captured.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -69,6 +75,7 @@ }, "share.form.kind" : { "comment" : "Picker label for the evidence kind.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -80,6 +87,7 @@ }, "share.form.noteHeader" : { "comment" : "Section header above the optional note field.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -91,6 +99,7 @@ }, "share.form.notePlaceholder" : { "comment" : "Placeholder for the optional note text field.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -102,6 +111,7 @@ }, "share.form.otherLabel" : { "comment" : "Placeholder for the free-text label shown when the kind is Other.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -113,6 +123,7 @@ }, "share.loading" : { "comment" : "Progress label while the shared attachment is being read.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -124,6 +135,7 @@ }, "share.ok" : { "comment" : "Dismiss button on the save-failure alert.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -135,6 +147,7 @@ }, "share.save" : { "comment" : "Save button persisting the shared evidence.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -146,6 +159,7 @@ }, "share.saveError.title" : { "comment" : "Title of the alert shown when saving the shared evidence fails.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -157,6 +171,7 @@ }, "share.title" : { "comment" : "Navigation title of the share compose sheet.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -168,4 +183,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index c34cc78b..d8325a42 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -50,7 +50,7 @@ not back inline in a view. Group a component's whole appearance into one nested `Equatable` struct instead of adding loose properties to the top level. The existing groups — `CardStyle` / `CardStyles`, `CalendarStyle`, `AppIconStyle`, `TimelineStyle`, -`RegionMapStyle` — are the template. To add one: +`RegionMapStyle`, `RegionPickerStyle` — are the template. To add one: 1. Define the struct in a `WhereStylesheet` extension with a doc comment saying which component it styles and any invariants; nest further structs for @@ -68,6 +68,18 @@ owned by a single component on `Palette`, the few bespoke display faces on `RegionStyle` (not the stylesheet); adaptive system roles (`.secondary`) and `.accentColor` stay inline. +`RegionStyle` is **data-driven** and resolved through the environment: views +read `@Environment(\.regionStyles)` (a `RegionStyleResolver`) and call +`regionStyles.style(for: region)` — there is no global `region.style`. The +resolver is seeded by `whereBroadwayRoot(regionStyles:)`: the app passes +`WhereSession`'s live resolver (updated on launch + `changes()`), the widget +process one built from its `WidgetSnapshot`, and App Intents snippets one from +their services; the default empty resolver yields the fallback looks +(`RegionAppearanceCatalog.defaultAppearance(for:)`) for previews and the +region-map viewer. The catalog also owns the selectable color/emoji/symbol +option lists the picker shows. Don't reintroduce a global accessor or a +hardcoded per-region look in a view. + ### Trait-aware tokens Most tokens are fixed, but a slice derives from the `BContext` traits in diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 4fe3b487..94d53e3a 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -39,14 +39,26 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's ### Reusable views & styling -- **`OnboardingView`** — the first-run flow, driven by a `LifecycleStepUIBridge`. +- **`OnboardingView`** — the first-run flow, driven by a `LifecycleStepUIBridge`: + a paged intro, then picking up to five primary US regions (map or searchable + list) and giving each a look, then the location-permission ask. It commits the + picks as the tracked-region set + appearances before finishing. The intro also + offers **Restore from a backup**, which imports a backup (`.replace`) and skips + the manual pick/customize steps straight to the location ask. +- **`RegionPickerView` / `RegionCustomizeView`** — the shared primary-region + picker (segmented map/list) and per-region color/emoji/icon customization, + backed by `PrimaryRegionSelectionModel`. Reused by onboarding and the Settings + `RegionsSettingsView` editor. - **Widget views** — the shared renderers the **WhereWidgets** extension draws with: `TodayWidgetView`, `YearTotalsWidgetView`, and the accessory family (`TodayInlineAccessoryView`, `TodayCircularAccessoryView`, `YearTotalsRectangularAccessoryView`). Each takes a `WidgetSnapshot`. -- **`RegionStyle`** — a region's symbol, emoji, and tint (read as - `region.style`); the per-region look shared across cards, calendar dots, and - timelines. +- **`RegionStyle` / `RegionStyleResolver`** — a region's symbol, emoji, and + tint, shared across cards, calendar dots, and timelines. Views resolve it from + `@Environment(\.regionStyles)` (`regionStyles.style(for: region)`), seeded by + `whereBroadwayRoot(regionStyles:)` — from `WhereSession`'s live resolver in the + app, the `WidgetSnapshot` in the widget process, and services in App Intents — + falling back to a deterministic default from `RegionAppearanceCatalog`. - **`whereBroadwayRoot()`** — seeds the Broadway design-system context so descendants resolve the `WhereStylesheet` tokens (see [Design system](#design-system)). Applied by `RootView` and by each widget. diff --git a/Where/WhereUI/Sources/Developer/RegionMapView.swift b/Where/WhereUI/Sources/Developer/RegionMapView.swift index 1ce52a44..e1e13f23 100644 --- a/Where/WhereUI/Sources/Developer/RegionMapView.swift +++ b/Where/WhereUI/Sources/Developer/RegionMapView.swift @@ -25,6 +25,7 @@ public struct RegionMapView: View { @State private var cameraPosition: MapCameraPosition = .automatic @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles /// Public so the standalone `RegionViewer` app (a separate module) can /// present the same screen as the in-app developer overlay entry. @@ -228,7 +229,7 @@ public struct RegionMapView: View { /// gets a stable color derived from its title so the same feature is /// always the same hue across launches. private func color(forTitle title: String, region: Region?) -> Color { - if let region { return region.style.tint } + if let region { return regionStyles.style(for: region).tint } return Self.palette[Self.paletteIndex(for: title)] } diff --git a/Where/WhereUI/Sources/Intents/IntentSnippets.swift b/Where/WhereUI/Sources/Intents/IntentSnippets.swift index 81e5efc7..76ffc3d1 100644 --- a/Where/WhereUI/Sources/Intents/IntentSnippets.swift +++ b/Where/WhereUI/Sources/Intents/IntentSnippets.swift @@ -29,14 +29,16 @@ public struct DaysInRegionSnippetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var region: Region { snapshot.region } public var body: some View { - HStack(spacing: stylesheet.spacing.medium) { - Text(region.style.emoji) + let style = regionStyles.style(for: region) + return HStack(spacing: stylesheet.spacing.medium) { + Text(style.emoji) // Semantic Dynamic Type face, matching TodayWidgetView's hero // emoji — no hardcoded point size. .font(.largeTitle) @@ -44,7 +46,7 @@ public struct DaysInRegionSnippetView: View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { Text(snapshot.dayCount, format: .number) .font(.system(.largeTitle, design: .rounded, weight: .bold)) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) .contentTransition(.numericText()) Text(caption) .font(.subheadline) @@ -103,6 +105,7 @@ public struct RegionsSnippetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles public var body: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.small) { @@ -124,12 +127,13 @@ public struct RegionsSnippetView: View { private var chips: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { ForEach(regions, id: \.self) { region in + let style = regionStyles.style(for: region) HStack(spacing: stylesheet.spacing.small) { - Text(region.style.emoji) + Text(style.emoji) .accessibilityHidden(true) Text(region.localizedName) .font(.headline) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) } } } @@ -185,7 +189,7 @@ extension RegionsSnippetView { ) { Button("Log today here") {} .buttonStyle(.borderedProminent) - .tint(Region.california.style.tint) + .tint(RegionStyle.fallbackStyle(for: .california).tint) .frame(maxWidth: .infinity) } .whereBroadwayRoot() diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index d9f8cb1d..f1c9e8a0 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -148,6 +148,8 @@ public enum WhereLaunch { LifecycleStep.work(LaunchStepID.syncAuth) { _ in await model.session?.syncAuthorization() model.session?.observeAuthorizationChanges() + await model.session?.seedRegionStyles() + model.session?.observeRegionStyleChanges() } LifecycleStep.work(LaunchStepID.reconcileTracking) { _ in await model.session?.reconcileTracking() diff --git a/Where/WhereUI/Sources/Manual/ManualDayView.swift b/Where/WhereUI/Sources/Manual/ManualDayView.swift index 5d922230..2e1040f2 100644 --- a/Where/WhereUI/Sources/Manual/ManualDayView.swift +++ b/Where/WhereUI/Sources/Manual/ManualDayView.swift @@ -42,6 +42,10 @@ struct ManualDayView: View { @State private var deleteError = SaveErrorAlertState() @State private var showDeleteConfirmation = false @State private var pending: PendingWrite? + /// Whether the collapsed "more regions" group is expanded. + @State private var showAllRegions = false + + private static let logger = WhereLog.channel(.model) init(report: YearReportModel, mode: Mode, showsCancelButton: Bool = false) { self.report = report @@ -60,6 +64,7 @@ struct ManualDayView: View { } } .animation(.default, value: pending) + .task { await loadGrouping() } .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -198,15 +203,71 @@ struct ManualDayView: View { // MARK: - Shared sections + @ViewBuilder private func regionsSection(_ regions: RegionSelectionState) -> some View { - Section { - ForEach(regions.items) { item in - RegionToggleRow(item: item) + if regions.trackedRegions == nil { + // Tracked set not loaded yet (or a preview without services): show + // the flat catalog list, matching the pre-grouping behavior. + Section { + ForEach(regions.items) { RegionToggleRow(item: $0) } + } header: { + Text(Strings.manualRegionsHeader) + } footer: { + Text(Strings.manualRegionsFooter) } - } header: { - Text(Strings.manualRegionsHeader) - } footer: { - Text(Strings.manualRegionsFooter) + } else { + if !regions.trackedItems.isEmpty { + Section { + ForEach(regions.trackedItems) { RegionToggleRow(item: $0) } + } header: { + Text(Strings.manualRegionsTrackedHeader) + } footer: { + Text(Strings.manualRegionsFooter) + } + } + if !regions.usedItems.isEmpty { + Section { + ForEach(regions.usedItems) { RegionToggleRow(item: $0) } + } header: { + Text(Strings.manualRegionsUsedHeader) + } + } + Section { + DisclosureGroup(isExpanded: $showAllRegions) { + ForEach(regions.otherItems) { RegionToggleRow(item: $0) } + } label: { + Text(Strings.manualRegionsMore) + } + } + } + } + + /// The region-selection state for the active mode. + private var activeRegions: RegionSelectionState { + switch fields { + case let .add(add): add.regions + case let .edit(edit): edit.regions + } + } + + /// Load the tracked/primary regions + the regions used this year once so the + /// toggles can be grouped (tracked / used-this-year / everything else). On + /// failure the form keeps the flat list rather than a broken grouping, and + /// logs. Expands the collapsed group when a currently-on region landed in it, + /// so an existing tag is never hidden. + private func loadGrouping() async { + guard activeRegions.trackedRegions == nil else { return } + let usedThisYear = Set( + (report.report?.totals ?? [:]).filter { $0.value > 0 }.map(\.key), + ) + do { + let tracked = try await report.services.primaryRegions() + activeRegions.applyGrouping(tracked: tracked, usedThisYear: usedThisYear) + if activeRegions.otherItems.contains(where: \.isOn) { + showAllRegions = true + } + } catch { + Self.logger.warning("Manual-day form couldn't load regions for grouping") } } diff --git a/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift new file mode 100644 index 00000000..57a94d29 --- /dev/null +++ b/Where/WhereUI/Sources/Model/RegionAppearanceCatalog.swift @@ -0,0 +1,151 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// The predefined option sets the region-customization UI offers — the colors, +/// emoji, and SF Symbols a user picks from — plus the mapping from a stored +/// ``RegionColorToken`` to a concrete SwiftUI `Color`. Presentation data, so it +/// lives in `WhereUI` next to the pickers and `RegionStyle` (which shares the +/// color mapping and default look). +public enum RegionAppearanceCatalog { + /// Selectable accent colors, in the order the picker lays them out. The full + /// token set — matching `RegionStyle`'s historical default palette so a + /// picked color reads the same as an auto-assigned one. + public static let colors: [RegionColorToken] = RegionColorToken.allCases + + /// Selectable emoji, grouped loosely by theme (places, nature, transit, + /// weather, symbols) for a scannable grid. + public static let emojis: [String] = [ + "📍", + "🏙️", + "🏖️", + "🏔️", + "🏜️", + "🌆", + "🌉", + "🗽", + "🏛️", + "🏰", + "🌴", + "🌵", + "🌲", + "🌾", + "🍁", + "🌻", + "🌊", + "⛰️", + "🏝️", + "🏞️", + "✈️", + "🚗", + "🚂", + "⛵️", + "🚀", + "🧭", + "🗺️", + "⭐️", + "❤️", + "🔥", + "☀️", + "🌙", + "❄️", + "🌈", + "⚡️", + "🎡", + "🎢", + "🎸", + "🍷", + "☕️", + ] + + /// Selectable SF Symbols, all guaranteed-available multicolor-friendly + /// location/place glyphs. + public static let symbols: [String] = [ + "mappin.circle.fill", + "location.fill", + "map.fill", + "flag.fill", + "house.fill", + "building.2.fill", + "building.columns.fill", + "tent.fill", + "sun.max.fill", + "moon.stars.fill", + "cloud.fill", + "snowflake", + "leaf.fill", + "tree.fill", + "mountain.2.fill", + "water.waves", + "airplane", + "car.fill", + "tram.fill", + "sailboat.fill", + "star.fill", + "heart.fill", + "flame.fill", + "bolt.fill", + "globe.americas.fill", + "beach.umbrella.fill", + "camera.fill", + "sparkles", + ] + + /// The default appearance for `region` when the user hasn't picked one — the + /// starting point the customization step pre-fills, and the fallback + /// `RegionStyle.fallbackStyle(for:)` renders. A small table of hand-tuned + /// looks for the regions the app shipped with (and the `.other` catch-all); + /// an id-derived color + generic map-pin glyph for everything else, so the + /// same region is always the same color across launches. + public static func defaultAppearance(for region: Region) -> RegionAppearance { + bespokeAppearances[region.rawValue] + ?? RegionAppearance( + color: defaultColor(for: region.rawValue), + emoji: "📍", + symbolName: "mappin.circle.fill", + ) + } + + private static let bespokeAppearances: [String: RegionAppearance] = [ + Region.california.rawValue: + RegionAppearance(color: .orange, emoji: "🌴", symbolName: "sun.max.fill"), + Region.newYork.rawValue: + RegionAppearance(color: .indigo, emoji: "🗽", symbolName: "building.2.fill"), + Region.canada.rawValue: + RegionAppearance(color: .red, emoji: "🍁", symbolName: "leaf.fill"), + Region.europeanUnion.rawValue: + RegionAppearance(color: .blue, emoji: "🇪🇺", symbolName: "star.fill"), + // `.other` keeps its original catch-all look; `location.magnifyingglass` + // isn't in the pickable `symbols` catalog, but a fallback default isn't + // constrained to the picker's options. + Region.other.rawValue: + RegionAppearance(color: .teal, emoji: "🧭", symbolName: "location.magnifyingglass"), + ] + + /// A stable accent token derived from the region id, matching the order of + /// ``colors`` so an auto-assigned color is one the picker can also show. + private static func defaultColor(for id: String) -> RegionColorToken { + let sum = id.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } + return colors[sum % colors.count] + } +} + +extension RegionColorToken { + /// The concrete SwiftUI color for this token. Shared by `RegionStyle` and the + /// color picker so a swatch and the rendered card always match. + public var color: Color { + switch self { + case .orange: .orange + case .indigo: .indigo + case .red: .red + case .blue: .blue + case .teal: .teal + case .green: .green + case .mint: .mint + case .cyan: .cyan + case .purple: .purple + case .pink: .pink + case .brown: .brown + } + } +} diff --git a/Where/WhereUI/Sources/Model/RegionStyle.swift b/Where/WhereUI/Sources/Model/RegionStyle.swift index 40c6f1ad..c48ec088 100644 --- a/Where/WhereUI/Sources/Model/RegionStyle.swift +++ b/Where/WhereUI/Sources/Model/RegionStyle.swift @@ -21,75 +21,21 @@ public struct RegionStyle: Sendable { self.tint = tint } - public static func style(for region: Region) -> RegionStyle { - bespokeStyles[region.rawValue] ?? defaultStyle(for: region) - } - - /// Hand-tuned looks for the regions the app shipped with (and the `.other` - /// catch-all). - /// - /// TODO: Remove when user-chosen per-region styling lands — these bespoke - /// looks become user data (a picked emoji / symbol / tint). Deleting this - /// one table cleanly falls back to `defaultStyle(for:)`; nothing else here - /// hard-codes a region. - private static let bespokeStyles: [String: RegionStyle] = [ - Region.california.rawValue: RegionStyle( - symbolName: "sun.max.fill", - emoji: "🌴", - tint: .orange, - ), - Region.newYork.rawValue: RegionStyle( - symbolName: "building.2.fill", - emoji: "🗽", - tint: .indigo, - ), - Region.canada.rawValue: RegionStyle(symbolName: "leaf.fill", emoji: "🍁", tint: .red), - Region.europeanUnion.rawValue: RegionStyle( - symbolName: "star.circle.fill", - emoji: "🇪🇺", - tint: .blue, - ), - Region.other.rawValue: RegionStyle( - symbolName: "location.magnifyingglass", - emoji: "🧭", - tint: .teal, - ), - ] - - /// A stable default look for any region without a bespoke entry: a generic - /// map-pin symbol/emoji and a tint chosen deterministically from the id, so - /// the same region is always the same color across launches. - private static func defaultStyle(for region: Region) -> RegionStyle { - RegionStyle( - symbolName: "mappin.circle.fill", - emoji: "📍", - tint: defaultPalette[paletteIndex(for: region.rawValue)], + /// Build a style from a user-picked ``RegionAppearance`` (token → color). + public init(_ appearance: RegionAppearance) { + self.init( + symbolName: appearance.symbolName, + emoji: appearance.emoji, + tint: appearance.color.color, ) } - private static let defaultPalette: [Color] = [ - .orange, - .indigo, - .red, - .blue, - .teal, - .green, - .mint, - .cyan, - .purple, - .pink, - .brown, - ] - - private static func paletteIndex(for id: String) -> Int { - let sum = id.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } - return sum % defaultPalette.count - } -} - -extension Region { - /// Convenience accessor so views can write `region.style`. - public var style: RegionStyle { - RegionStyle.style(for: self) + /// The look for a region with no user-picked appearance: the region's + /// default ``RegionAppearance`` (a hand-tuned look for the regions the app + /// shipped with, an id-derived default for everything else). Sharing + /// `RegionAppearanceCatalog.defaultAppearance(for:)` keeps the fallback and + /// the customization pre-fill in lockstep — a picked appearance always wins. + public static func fallbackStyle(for region: Region) -> RegionStyle { + RegionStyle(RegionAppearanceCatalog.defaultAppearance(for: region)) } } diff --git a/Where/WhereUI/Sources/Model/RegionStyleResolver.swift b/Where/WhereUI/Sources/Model/RegionStyleResolver.swift new file mode 100644 index 00000000..c0993b1f --- /dev/null +++ b/Where/WhereUI/Sources/Model/RegionStyleResolver.swift @@ -0,0 +1,51 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Resolves a `Region` to its `RegionStyle`, using the user's picked +/// ``RegionAppearance``s when present and falling back to the deterministic +/// default look otherwise. +/// +/// Injected into the view environment (`\.regionStyles`) by `whereBroadwayRoot`, +/// so every surface resolves the *same* styles from a single seeded value rather +/// than a global: the app seeds it from `WhereSession` (live on store changes), +/// the widget process from its `WidgetSnapshot`, and App Intents snippets from +/// their services. An empty resolver (the environment default) yields the +/// fallback looks — correct for previews, the standalone region-map viewer, and +/// any surface that hasn't been seeded. +public struct RegionStyleResolver: Sendable, Equatable { + private let appearances: [Region: RegionAppearance] + + /// The empty resolver: every region resolves to its fallback look. + public static let `default` = RegionStyleResolver(appearances: [:]) + + public init(appearances: [Region: RegionAppearance]) { + self.appearances = appearances + } + + /// Build from ordered `PrimaryRegion`s, keeping only the ones that carry a + /// resolved appearance. + public init(primaryRegions: [PrimaryRegion]) { + var map: [Region: RegionAppearance] = [:] + for entry in primaryRegions { + if let appearance = entry.appearance { map[entry.region] = appearance } + } + self.init(appearances: map) + } + + /// The region's look: its picked appearance if customized, else the stable + /// fallback (a hand-tuned look for the shipped regions, an id-derived default + /// otherwise). + public func style(for region: Region) -> RegionStyle { + if let appearance = appearances[region] { + return RegionStyle(appearance) + } + return RegionStyle.fallbackStyle(for: region) + } +} + +extension EnvironmentValues { + /// The active region-style resolver, seeded by `whereBroadwayRoot`. Defaults + /// to the empty resolver (fallback looks) when nothing seeded it. + @Entry public var regionStyles: RegionStyleResolver = .default +} diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 11619564..75e01f3b 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -77,6 +77,17 @@ public final class WhereSession { /// access to race. @ObservationIgnored private nonisolated(unsafe) var authorizationTask: Task? + /// Observes `dataChangeUpdates()` to keep ``regionStyles`` in sync with the + /// store's picked region appearances. Same `nonisolated(unsafe)` rationale as + /// `authorizationTask` — only touched on the main actor except `deinit`. + @ObservationIgnored private nonisolated(unsafe) var regionStyleTask: Task? + + /// The user's picked region looks, resolved for the view environment (seeded + /// into `whereBroadwayRoot(regionStyles:)` by `RootView`). Loaded at launch + /// and kept live on every store change, so a Settings edit or a synced pick + /// from another device restyles the UI without a relaunch. + public private(set) var regionStyles: RegionStyleResolver = .default + private static let logger = WhereLog.channel(.session) /// The authorization the degradation warning was last evaluated against. @@ -136,6 +147,7 @@ public final class WhereSession { /// until the next status change resumes it. deinit { authorizationTask?.cancel() + regionStyleTask?.cancel() } /// Sync authorization, resume tracking if appropriate, apply the reminder / @@ -149,6 +161,8 @@ public final class WhereSession { public func start() async { await syncAuthorization() observeAuthorizationChanges() + await seedRegionStyles() + observeRegionStyleChanges() await reconcileTracking() await captureTodayIfNeeded() await applyReminderConfiguration() @@ -233,6 +247,34 @@ public final class WhereSession { } } + /// Load the user's picked region appearances into ``regionStyles`` so the + /// UI resolves them everywhere it renders a region. A launch step (see + /// `WhereLaunch.sequence`); also re-run on every store change via + /// `observeRegionStyleChanges()`. On failure it keeps the last good resolver + /// (honest degraded state) and logs. + func seedRegionStyles() async { + do { + let primary = try await services.primaryRegions() + regionStyles = RegionStyleResolver(primaryRegions: primary) + } catch { + Self.logger.warning("Failed to load region appearances for styling") + } + } + + /// Subscribe to store changes (local commits + remote CloudKit imports) so a + /// customized region's look stays live — a Settings edit or a synced pick on + /// another device reloads ``regionStyles``. Idempotent. + func observeRegionStyleChanges() { + guard regionStyleTask == nil else { return } + let services = services + regionStyleTask = Task { @MainActor [weak self] in + for await _ in services.dataChangeUpdates() { + guard let self else { break } + await seedRegionStyles() + } + } + } + /// Start or stop GPS ingestion so it matches the user's intent and the /// current authorization. Tracking only runs with Always authorization. A /// launch step (see `WhereLaunch.sequence`). diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift index 8e80c918..ba9d0444 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingView.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingView.swift @@ -1,28 +1,56 @@ import LifecycleKit import SwiftUI +import UniformTypeIdentifiers import WhereCore -/// First-run onboarding: a short paged intro to the passport concept that -/// culminates in the background-location permission request — the natural -/// place to ask for Always, rather than burying it in Settings. +/// First-run onboarding, run as one interactive launch step. A short paged +/// intro to the passport concept, then picking the primary regions you spend +/// time in and giving each a look, then the background-location permission +/// request — the natural place to ask for Always, rather than burying it in +/// Settings. /// -/// Presented as the UI of the launch sequence's interactive `onboarding` step. -/// When the user finishes, it persists `hasOnboarded` and resolves the -/// `LifecycleStepUIBridge` so the launch continues; the existing -/// authorization-sync and tracking-reconcile steps then pick up whatever +/// When the user finishes it commits the picked regions + appearances to the +/// store (which becomes the tracked-region set), persists `hasOnboarded`, and +/// resolves the `LifecycleStepUIBridge` so the launch continues; the following +/// authorization-sync step then seeds region styling and picks up whatever /// permission was granted. public struct OnboardingView: View { // Onboarding straddles both: it persists the app-level `hasOnboarded` flag - // (model) and kicks off background tracking through the session, which the - // `open-store` step has already built by the time this step runs. + // (model) and kicks off background tracking + the region commit through the + // session, which the `open-store` step has already built by the time this + // step runs. @Environment(WhereModel.self) private var model @Environment(WhereSession.self) private var session @Environment(\.stylesheet) private var stylesheet private let bridge: LifecycleStepUIBridge + /// The ordered onboarding phases. An explicit state machine (rather than + /// loose flags) so only one screen is ever showing and the transitions are + /// obvious. + private enum Phase: Hashable { + case intro + case pickRegions + case customize + case location + } + + @State private var phase: Phase = .intro @State private var page = 0 + @State private var selection = PrimaryRegionSelectionModel() @State private var isFinishing = false + // Restore-from-backup: the escape hatch that skips manual region setup. + @State private var showImporter = false + @State private var isRestoring = false + @State private var showRestoreError = false + @State private var restoreError: String? + /// Set once a backup restore succeeds, so the location-finale `finish` skips + /// committing the (empty) manual selection — which would otherwise wipe the + /// regions the restore just wrote. + @State private var didRestoreBackup = false + + private static let logger = WhereLog.channel(.model) + public init(bridge: LifecycleStepUIBridge) { self.bridge = bridge } @@ -30,19 +58,13 @@ public struct OnboardingView: View { private let pages = OnboardingPage.all public var body: some View { - VStack(spacing: stylesheet.spacing.xxxLarge) { - TabView(selection: $page) { - ForEach(pages.indices, id: \.self) { index in - pageView(pages[index]) - .tag(index) - .padding(.horizontal, stylesheet.spacing.xxxLarge) - } + Group { + switch phase { + case .intro: intro + case .pickRegions: pickRegions + case .customize: customize + case .location: location } - .tabViewStyle(.page(indexDisplayMode: .always)) - - footer - .padding(.horizontal, stylesheet.spacing.xxxLarge) - .padding(.bottom, stylesheet.spacing.xxxLarge) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background( @@ -56,6 +78,40 @@ public struct OnboardingView: View { ) .ignoresSafeArea(), ) + .animation(stylesheet.motion.reducedReveal, value: phase) + } + + // MARK: - Intro + + private var intro: some View { + VStack(spacing: stylesheet.spacing.xxxLarge) { + TabView(selection: $page) { + ForEach(pages.indices, id: \.self) { index in + pageView(pages[index]) + .tag(index) + .padding(.horizontal, stylesheet.spacing.xxxLarge) + } + } + .tabViewStyle(.page(indexDisplayMode: .always)) + + introFooter + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) + } + .fileImporter( + isPresented: $showImporter, + allowedContentTypes: [.zip], + onCompletion: handleRestoreSelection, + ) + .alert( + Strings.onboardingRestoreErrorTitle, + isPresented: $showRestoreError, + presenting: restoreError, + ) { _ in + Button(Strings.commonOK, role: .cancel) {} + } message: { message in + Text(message) + } } private func pageView(_ page: OnboardingPage) -> some View { @@ -78,28 +134,85 @@ public struct OnboardingView: View { } } - @ViewBuilder private var footer: some View { - if page < pages.count - 1 { + private var introFooter: some View { + VStack(spacing: stylesheet.spacing.medium) { Button { - withAnimation { page += 1 } + if page < pages.count - 1 { + withAnimation { page += 1 } + } else { + phase = .pickRegions + } } label: { Text(Strings.onboardingContinue) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) - } else { + + // Returning users can skip the manual setup by restoring a backup. + if isRestoring { + ProgressView(Strings.onboardingRestoring) + } else { + Button(Strings.onboardingRestoreBackup) { showImporter = true } + .controlSize(.large) + } + } + .disabled(isRestoring) + } + + // MARK: - Pick regions + + private var pickRegions: some View { + NavigationStack { + RegionPickerView(model: selection) + .navigationTitle(Strings.onboardingRegionsTitle) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(Strings.onboardingNext) { phase = .customize } + .disabled(!selection.hasSelection) + } + } + } + } + + // MARK: - Customize + + private var customize: some View { + NavigationStack { + RegionCustomizeView( + model: selection, + onBack: { phase = .pickRegions }, + onFinish: { phase = .location }, + ) + } + } + + // MARK: - Location + + private var location: some View { + VStack(spacing: stylesheet.spacing.xxxLarge) { + Spacer(minLength: 0) + Image(systemName: "location.fill.viewfinder") + .font(stylesheet.typography.onboardingIcon) + .foregroundStyle(Color.accentColor) + .accessibilityHidden(true) + VStack(spacing: stylesheet.spacing.large) { + Text(Strings.onboardingLocationTitle) + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + Text(Strings.onboardingLocationDescription) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Spacer(minLength: 0) + VStack(spacing: stylesheet.spacing.large) { Button { // Request Always-location right here so the system prompt // maps 1:1 to the tap; the launch's tracking-reconcile step // picks up whatever was granted. - guard !isFinishing else { return } - isFinishing = true - Task { - await session.startTracking() - completeAndContinue() - } + finish(enableLocation: true) } label: { Text(Strings.onboardingEnableLocation) .frame(maxWidth: .infinity) @@ -108,22 +221,73 @@ public struct OnboardingView: View { .controlSize(.large) Button(Strings.onboardingNotNow) { - guard !isFinishing else { return } - isFinishing = true - completeAndContinue() + finish(enableLocation: false) } .controlSize(.large) } .disabled(isFinishing) } + .padding(.horizontal, stylesheet.spacing.xxxLarge) + .padding(.bottom, stylesheet.spacing.xxxLarge) } - /// Persist that onboarding is done and resolve the step's bridge so the - /// launch continues. Shared by the "Enable Location" and "Not now" taps; - /// the permission request, when wanted, is made by the button before this. - private func completeAndContinue() { - model.completeOnboarding() - bridge.complete() + /// Commit the picked regions + appearances, optionally request location, + /// then persist `hasOnboarded` and resolve the step so the launch continues. + private func finish(enableLocation: Bool) { + guard !isFinishing else { return } + isFinishing = true + Task { + if enableLocation { + await session.startTracking() + } + // A restore already wrote the primary regions; committing the (empty) + // manual selection here would wipe them, so only commit the picks + // when the user came through the manual flow. + if !didRestoreBackup { + do { + try await selection.commit(using: session) + } catch { + // Don't strand the user in onboarding on a write failure — + // log it and continue; they can re-pick in Settings. + Self.logger.warning("Failed to commit onboarding region picks") + } + } + model.completeOnboarding() + bridge.complete() + } + } + + // MARK: - Restore from backup + + private func handleRestoreSelection(_ result: Result) { + switch result { + case let .success(url): + restore(from: url) + case let .failure(error): + restoreError = error.localizedDescription + showRestoreError = true + } + } + + /// Import the chosen backup (a fresh install, so `.replace` mirrors the file + /// exactly), then skip the manual pick/customize steps straight to the + /// location ask. On failure, surface an alert and stay in the intro. + private func restore(from url: URL) { + guard !isRestoring else { return } + isRestoring = true + Task { + do { + _ = try await session.services.backup.importBackup(from: url, strategy: .replace) + didRestoreBackup = true + isRestoring = false + phase = .location + } catch { + isRestoring = false + restoreError = error.localizedDescription + showRestoreError = true + Self.logger.warning("Onboarding backup restore failed") + } + } } } diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index 263844fa..4e41dc96 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -70,6 +70,38 @@ WhereSession(services: previewServices()) } + // MARK: - Region picker / customization + + /// A primary-region selection model seeded with a few US picks + looks, + /// for the picker/customization previews and tests. + @MainActor + public static func primaryRegionSelectionModel() -> PrimaryRegionSelectionModel { + let texas = Region(rawValue: "us-TX") + let existing: [PrimaryRegion] = [ + PrimaryRegion( + region: .california, + appearance: RegionAppearance( + color: .orange, + emoji: "🌴", + symbolName: "sun.max.fill", + ), + order: 0, + ), + PrimaryRegion( + region: .newYork, + appearance: RegionAppearance( + color: .indigo, + emoji: "🗽", + symbolName: "building.2.fill", + ), + order: 1, + ), + ] + (texas.map { + [PrimaryRegion(region: $0, appearance: nil, order: 2)] + } ?? []) + return PrimaryRegionSelectionModel(existing: existing) + } + // MARK: - Report models (scene / report + year views) /// A ready-to-render scene report model with the sample report injected diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index 083c88f8..789b7419 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -234,6 +234,7 @@ private struct MonthFooter: View { var focusedRegion: Region? @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var calendar: WhereStylesheet.CalendarStyle { stylesheet.calendar @@ -252,7 +253,7 @@ private struct MonthFooter: View { let isFocused = tally.region == focusedRegion return HStack(spacing: calendar.month.footerRowSpacing) { Circle() - .fill(tally.region.style.tint) + .fill(regionStyles.style(for: tally.region).tint) .frame( width: calendar.dotSize, height: calendar.dotSize, @@ -282,6 +283,7 @@ private struct DayCell: View { let day: CalendarDayCell @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var calendar: WhereStylesheet.CalendarStyle { stylesheet.calendar @@ -323,7 +325,7 @@ private struct DayCell: View { HStack(spacing: calendar.dayContentSpacing) { ForEach(day.regions, id: \.self) { region in Circle() - .fill(region.style.tint) + .fill(regionStyles.style(for: region).tint) .frame( width: calendar.dotSize, height: calendar.dotSize, diff --git a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift index 000271dc..7a615fe3 100644 --- a/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift +++ b/Where/WhereUI/Sources/Primary/PresenceTimelineView.swift @@ -74,13 +74,14 @@ private struct StintRow: View { let stint: RegionStint @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var timeline: WhereStylesheet.TimelineStyle { stylesheet.timeline } private var style: RegionStyle { - stint.region.style + regionStyles.style(for: stint.region) } var body: some View { diff --git a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift index d495c532..f8b79121 100644 --- a/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift +++ b/Where/WhereUI/Sources/Primary/RegionSummaryCard.swift @@ -37,7 +37,14 @@ struct RegionSummaryCard: View { /// static sheen. var tilt: TiltProvider? + /// An explicit style to render instead of resolving the region's look from + /// `\.regionStyles`. The region-customization screen passes the in-progress + /// draft appearance so the card previews a pick before it's saved; every + /// other caller leaves it `nil` and gets the resolved look. + var styleOverride: RegionStyle? + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles /// The resolved spec for this card's variant, read once so the rest of the /// view is a straight-line render with no `compact` branching. @@ -46,7 +53,7 @@ struct RegionSummaryCard: View { } private var style: RegionStyle { - regionDays.region.style + styleOverride ?? regionStyles.style(for: regionDays.region) } private var cardShape: RoundedRectangle { diff --git a/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift new file mode 100644 index 00000000..a2e5d49a --- /dev/null +++ b/Where/WhereUI/Sources/Regions/PrimaryRegionSelectionModel.swift @@ -0,0 +1,134 @@ +import Foundation +import Observation +import RegionKit +import WhereCore + +/// The view-scoped model behind the primary-region picker + customization flow, +/// shared by onboarding and Settings. Holds the ordered selection (capped at +/// ``maxSelection``), each region's in-progress ``RegionAppearance`` draft, and +/// the commit that syncs the choice to the store through a `WhereServices` +/// collaborator. +/// +/// The order of ``selectedRegions`` is the pick order — it drives the +/// customization step sequence and the stored `PrimaryRegion.order`. +@MainActor +@Observable +public final class PrimaryRegionSelectionModel { + /// The most primary regions a user can pick. + public static let maxSelection = 5 + + /// The regions offered, in catalog order (US jurisdictions only for now). + public let available: [Region] + + /// The picked regions, in pick order. + public private(set) var selectedRegions: [Region] = [] + + /// Per-region appearance drafts. A region without an entry uses its + /// ``RegionAppearanceCatalog/defaultAppearance(for:)`` until customized. + private var drafts: [Region: RegionAppearance] = [:] + + /// The US jurisdictions from the catalog, in canonical order — the default + /// `available` set for the picker. + public static var usRegions: [Region] { + RegionCatalog.shared.all.filter { $0.rawValue.hasPrefix("us-") } + } + + /// A fresh picker with nothing selected (onboarding). + public init(available: [Region] = PrimaryRegionSelectionModel.usRegions) { + self.available = available + } + + /// A picker seeded from the user's existing primary regions (Settings). + /// Selection and drafts are restored so re-opening the editor shows the + /// current picks. + /// + /// Regions outside `available` are dropped from the selection, so committing + /// removes them (the commit replaces the whole primary set with what's + /// selected). This is intentional: the app is US-only now, and the only + /// non-US regions a user can have are the legacy default set (Canada / the + /// EU, whose low-resolution polygons we no longer ship as pickable) — so a + /// fresh install that opens the editor and saves converges to the US picks. + public init( + existing: [PrimaryRegion], + available: [Region] = PrimaryRegionSelectionModel.usRegions, + ) { + self.available = available + let offered = Set(available) + selectedRegions = existing.map(\.region).filter { offered.contains($0) } + var drafts: [Region: RegionAppearance] = [:] + for entry in existing { + if let appearance = entry.appearance { drafts[entry.region] = appearance } + } + self.drafts = drafts + } + + public var selectionCount: Int { + selectedRegions.count + } + + public var isAtCapacity: Bool { + selectedRegions.count >= Self.maxSelection + } + + public var hasSelection: Bool { + !selectedRegions.isEmpty + } + + public func isSelected(_ region: Region) -> Bool { + selectedRegions.contains(region) + } + + /// Whether tapping `region` would do something: always true when it's + /// already selected (a tap removes it), otherwise only when there's room. + public func canToggle(_ region: Region) -> Bool { + isSelected(region) || !isAtCapacity + } + + /// Add `region` (appending to preserve pick order) or remove it. A no-op + /// when adding past ``maxSelection``. + public func toggle(_ region: Region) { + if let index = selectedRegions.firstIndex(of: region) { + selectedRegions.remove(at: index) + } else if !isAtCapacity { + selectedRegions.append(region) + } + } + + /// The current appearance draft for `region` (its default until edited). + public func appearance(for region: Region) -> RegionAppearance { + drafts[region] ?? RegionAppearanceCatalog.defaultAppearance(for: region) + } + + public func setColor(_ color: RegionColorToken, for region: Region) { + var appearance = appearance(for: region) + appearance.color = color + drafts[region] = appearance + } + + public func setEmoji(_ emoji: String, for region: Region) { + var appearance = appearance(for: region) + appearance.emoji = emoji + drafts[region] = appearance + } + + public func setSymbol(_ symbolName: String, for region: Region) { + var appearance = appearance(for: region) + appearance.symbolName = symbolName + drafts[region] = appearance + } + + /// The picked regions as ordered ``PrimaryRegion`` values (pick order → + /// `order`), each carrying its current appearance draft. + public var desiredPrimaryRegions: [PrimaryRegion] { + selectedRegions.enumerated().map { index, region in + PrimaryRegion(region: region, appearance: appearance(for: region), order: index) + } + } + + /// Persist the selection by replacing the primary set with it — one atomic + /// transaction (upserts + removals-by-omission). Throws on failure so the + /// caller can surface it; no partial success is hidden. + public func commit(using session: WhereSession) async throws { + try await session.services.setPrimaryRegions(desiredPrimaryRegions) + } +} diff --git a/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift new file mode 100644 index 00000000..5b1e322b --- /dev/null +++ b/Where/WhereUI/Sources/Regions/RegionCustomizeView.swift @@ -0,0 +1,257 @@ +import RegionKit +import SwiftUI +import WhereCore + +/// Editor for one region's look: color, emoji, and icon grids picked from the +/// predefined `RegionAppearanceCatalog`, above a live `RegionSummaryCard` +/// preview that reflects the in-progress draft. Writes straight into the shared +/// ``PrimaryRegionSelectionModel`` draft; reused by the onboarding stepping flow +/// and the Settings editor. +struct RegionAppearanceEditor: View { + @Bindable var model: PrimaryRegionSelectionModel + let region: Region + + @Environment(\.stylesheet) private var stylesheet + + private var appearance: RegionAppearance { + model.appearance(for: region) + } + + var body: some View { + ScrollView { + VStack(spacing: stylesheet.spacing.xLarge) { + preview + + section(Strings.regionCustomizeColor) { + colorGrid + } + section(Strings.regionCustomizeEmoji) { + emojiGrid + } + section(Strings.regionCustomizeSymbol) { + symbolGrid + } + } + .padding(stylesheet.spacing.large) + } + } + + private var preview: some View { + RegionSummaryCard( + regionDays: RegionDays(region: region, days: 128), + caption: Strings.regionCustomizeSubtitle(region: region.localizedName), + styleOverride: RegionStyle(appearance), + ) + .animation(stylesheet.motion.captionFade, value: appearance) + } + + private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: stylesheet.spacing.medium) { + Text(title) + .font(.headline) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var style: WhereStylesheet.RegionPickerStyle { + stylesheet.regionPicker + } + + private var colorColumns: [GridItem] { + [GridItem( + .adaptive(minimum: style.colorSwatchMinWidth), + spacing: stylesheet.spacing.medium, + )] + } + + private var colorGrid: some View { + LazyVGrid(columns: colorColumns, spacing: stylesheet.spacing.medium) { + ForEach(RegionAppearanceCatalog.colors, id: \.self) { token in + let isSelected = appearance.color == token + Button { + model.setColor(token, for: region) + } label: { + Circle() + .fill(token.color) + .frame(width: style.colorSwatchSize, height: style.colorSwatchSize) + .overlay { + Circle() + .strokeBorder( + .primary, + lineWidth: isSelected ? style.colorSwatchSelectionRing : 0, + ) + } + .overlay { + if isSelected { + Image(systemName: "checkmark") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(Strings.regionColorAccessibility(token)) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + } + + private var glyphColumns: [GridItem] { + [GridItem(.adaptive(minimum: style.glyphTileMinWidth), spacing: stylesheet.spacing.small)] + } + + private var emojiGrid: some View { + LazyVGrid(columns: glyphColumns, spacing: stylesheet.spacing.small) { + ForEach(RegionAppearanceCatalog.emojis, id: \.self) { emoji in + let isSelected = appearance.emoji == emoji + Button { + model.setEmoji(emoji, for: region) + } label: { + Text(emoji) + .font(.title2) + .frame(width: style.glyphTileSize, height: style.glyphTileSize) + .background(selectionBackground(isSelected)) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + } + + private var symbolGrid: some View { + LazyVGrid(columns: glyphColumns, spacing: stylesheet.spacing.small) { + ForEach(RegionAppearanceCatalog.symbols, id: \.self) { symbol in + let isSelected = appearance.symbolName == symbol + Button { + model.setSymbol(symbol, for: region) + } label: { + Image(systemName: symbol) + .font(.title3) + .foregroundStyle(appearance.color.color) + .frame(width: style.glyphTileSize, height: style.glyphTileSize) + .background(selectionBackground(isSelected)) + } + .buttonStyle(.plain) + .accessibilityLabel(symbol) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + } + + private func selectionBackground(_ isSelected: Bool) -> some View { + RoundedRectangle(cornerRadius: style.glyphCornerRadius, style: .continuous) + .fill(isSelected + ? Color.accentColor.opacity(style.glyphSelectedBackgroundOpacity) + : Color(.secondarySystemBackground)) + .overlay { + RoundedRectangle(cornerRadius: style.glyphCornerRadius, style: .continuous) + .strokeBorder( + Color.accentColor, + lineWidth: isSelected ? style.glyphSelectionStrokeWidth : 0, + ) + } + } +} + +/// Steps through each picked region in pick order, editing its look with +/// ``RegionAppearanceEditor``. Back/Next (Done on the last region) live in the +/// navigation bar, so the caller must place this inside a `NavigationStack`. +/// `onFinish` fires when the user advances past the last region; `onBack` fires +/// when they go back before the first (so onboarding returns to the picker and +/// the Settings editor returns to its pick phase). +struct RegionCustomizeView: View { + @Bindable var model: PrimaryRegionSelectionModel + var onBack: () -> Void = {} + var onFinish: () -> Void = {} + + @State private var index = 0 + + @Environment(\.stylesheet) private var stylesheet + + private var regions: [Region] { + model.selectedRegions + } + + var body: some View { + Group { + if let region = currentRegion { + RegionAppearanceEditor(model: model, region: region) + .id(region) + .transition(.opacity) + } else { + // No selection to customize — nothing to step through. + ContentUnavailableView( + Strings.regionPickerTitle, + systemImage: "map", + description: Text(Strings.regionPickerSubtitle), + ) + } + } + .animation(stylesheet.motion.captionFade, value: index) + .navigationTitle(currentRegion?.localizedName ?? Strings.regionCustomizeTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(Strings.onboardingBack, action: goBack) + } + ToolbarItem(placement: .principal) { + Text(Strings.regionCustomizeStep(current: effectiveIndex + 1, total: regions.count)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + ToolbarItem(placement: .confirmationAction) { + Button(isLast ? Strings.commonDone : Strings.onboardingNext, action: goNext) + } + } + } + + /// `index` clamped into range, so a stale/out-of-range value can never + /// strand the user — the empty state (`currentRegion == nil`) is reserved for + /// a genuinely empty selection, which the current flow never reaches. + private var effectiveIndex: Int { + guard !regions.isEmpty else { return 0 } + return min(max(index, 0), regions.count - 1) + } + + private var currentRegion: Region? { + regions.isEmpty ? nil : regions[effectiveIndex] + } + + private var isLast: Bool { + effectiveIndex >= regions.count - 1 + } + + private func goBack() { + if effectiveIndex == 0 { + onBack() + } else { + index = effectiveIndex - 1 + } + } + + private func goNext() { + if isLast { + onFinish() + } else { + index = effectiveIndex + 1 + } + } +} + +#if DEBUG + #Preview("Editor") { + RegionAppearanceEditor( + model: PreviewSupport.primaryRegionSelectionModel(), + region: .california, + ) + .whereBroadwayRoot() + } + + #Preview("Stepping") { + NavigationStack { + RegionCustomizeView(model: PreviewSupport.primaryRegionSelectionModel()) + } + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Regions/RegionPickerView.swift b/Where/WhereUI/Sources/Regions/RegionPickerView.swift new file mode 100644 index 00000000..74015942 --- /dev/null +++ b/Where/WhereUI/Sources/Regions/RegionPickerView.swift @@ -0,0 +1,274 @@ +import MapKit +import RegionKit +import SwiftUI +import WhereCore + +/// Picks the user's primary regions — up to `PrimaryRegionSelectionModel.maxSelection` +/// — either by tapping states on a map or from a searchable list, switched via a +/// top-aligned segmented control. Selection lives in the shared +/// ``PrimaryRegionSelectionModel``; this view only reads/toggles it. Used inside +/// onboarding and the Settings region editor. +struct RegionPickerView: View { + @Bindable var model: PrimaryRegionSelectionModel + + /// Map vs list, the two ways to pick. + enum Mode: String, CaseIterable, Hashable { + case map + case list + } + + @State private var mode: Mode = .list + @State private var searchText = "" + /// Bumped whenever a map tap is ignored because the selection is full, to + /// drive the warning haptic. + @State private var capacityBumps = 0 + /// The loaded map geometry + a matching attributor for tap hit-testing. One + /// `Result` so "loading" (`nil`), success, and failure can't be confused. + @State private var mapData: Result? + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + + private static let logger = WhereLog.channel(.regionAttribution) + + var body: some View { + VStack(spacing: stylesheet.spacing.medium) { + modePicker + + VStack(spacing: stylesheet.spacing.xSmall) { + Text(Strings.regionPickerSelectionCount( + selected: model.selectionCount, + max: PrimaryRegionSelectionModel.maxSelection, + )) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.updatesFrequently) + + if model.isAtCapacity { + Text(Strings + .regionPickerAtCapacity(max: PrimaryRegionSelectionModel.maxSelection)) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .transition(.opacity) + } + } + .animation(stylesheet.motion.captionFade, value: model.isAtCapacity) + + switch mode { + case .map: + mapContent + case .list: + listContent + } + } + // A capped tap on the map is otherwise silent (unlike the list, which + // disables rows), so signal it with a warning haptic. + .sensoryFeedback(.warning, trigger: capacityBumps) + // Parse region geometry lazily — only once Map mode is actually shown, + // so a user who stays in the default List never pays the ~52-file parse. + .task(id: mode) { + guard mode == .map, mapData == nil else { return } + await loadMap() + } + } + + private var modePicker: some View { + Picker(Strings.regionPickerModePicker, selection: $mode) { + Text(Strings.regionPickerModeMap).tag(Mode.map) + Text(Strings.regionPickerModeList).tag(Mode.list) + } + .pickerStyle(.segmented) + .padding(.horizontal, stylesheet.spacing.large) + } + + // MARK: - Map + + @ViewBuilder + private var mapContent: some View { + switch mapData { + case .none: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .some(.failure(error)): + ContentUnavailableView( + Strings.regionPickerLoadErrorTitle, + systemImage: "map", + description: Text(error.localizedDescription), + ) + case let .some(.success(data)): + map(data) + } + } + + private func map(_ data: MapData) -> some View { + let style = stylesheet.regionPicker + return MapReader { proxy in + Map(initialPosition: .region(unitedStatesRegion)) { + ForEach(data.outlines) { outline in + let selected = outline.region.map(model.isSelected) ?? false + let tint = outline.region.map { regionStyles.style(for: $0).tint } ?? .gray + MapPolygon(coordinates: outline.coordinates.clLocationCoordinates) + .foregroundStyle(tint.opacity( + selected ? style.selectedFillOpacity : style.unselectedFillOpacity, + )) + .stroke( + tint.opacity( + selected + ? style.selectedStrokeOpacity + : style.unselectedStrokeOpacity, + ), + lineWidth: selected + ? style.selectedStrokeWidth + : style.unselectedStrokeWidth, + ) + } + } + .mapStyle(.standard(pointsOfInterest: .excludingAll)) + .onTapGesture { point in + guard let coordinate = proxy.convert(point, from: .local) else { return } + handleMapTap(at: coordinate, in: data) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipShape(RoundedRectangle(cornerRadius: style.mapCornerRadius, style: .continuous)) + .padding(.horizontal, stylesheet.spacing.large) + .accessibilityLabel(Strings.regionPickerMapAccessibility) + } + + private func handleMapTap(at coordinate: CLLocationCoordinate2D, in data: MapData) { + let region = data.attributor.region(at: Coordinate(coordinate)) + guard region != .other, model.available.contains(region) else { return } + guard model.canToggle(region) else { + // At capacity and tapping a new region — the list disables its rows + // to show this, but the map has no such affordance, so buzz instead + // of silently ignoring the tap. + capacityBumps += 1 + return + } + model.toggle(region) + } + + // MARK: - List + + private var filteredRegions: [Region] { + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return model.available } + return model.available.filter { + $0.localizedName.localizedCaseInsensitiveContains(query) + } + } + + private var listContent: some View { + List { + ForEach(filteredRegions, id: \.self) { region in + Button { + model.toggle(region) + } label: { + RegionPickerRow( + region: region, + isSelected: model.isSelected(region), + ) + } + .disabled(!model.canToggle(region)) + } + } + .listStyle(.plain) + .searchable(text: $searchText, prompt: Strings.regionPickerSearchPrompt) + .overlay { + if filteredRegions.isEmpty { + ContentUnavailableView.search(text: searchText) + } + } + } + + // MARK: - Loading + + /// The loaded US geometry plus the attributor built from the same regions, + /// so a map tap resolves to exactly the regions the outlines drew. + struct MapData { + let outlines: [RegionOutline] + let attributor: RegionAttributor + } + + private func loadMap() async { + let regions = model.available + do { + // Building the attributor parses every offered region's GeoJSON, so + // keep it off the main actor; the outline read is then cheap. + let attributor = await Task + .detached(priority: .userInitiated) { RegionAttributor(for: regions) } + .value + let outlines = try await RegionGeometryCatalog.outlines( + for: .attribution, + attributor: attributor, + ) + guard !Task.isCancelled else { return } + mapData = .success(MapData(outlines: outlines, attributor: attributor)) + } catch { + guard !Task.isCancelled else { return } + // Keep the failure observable in both the UI (error state) and the + // logs rather than showing a blank map. + Self.logger.warning("Region picker failed to load map geometry: \(error)") + mapData = .failure(error) + } + } + + /// A camera framed on the contiguous US, a sensible default for a US-only + /// picker (the user can pan to Alaska/Hawaii). Built from the stylesheet's + /// raw degrees so the token stays MapKit-free. + private var unitedStatesRegion: MKCoordinateRegion { + let style = stylesheet.regionPicker + return MKCoordinateRegion( + center: CLLocationCoordinate2D( + latitude: style.mapCenterLatitude, + longitude: style.mapCenterLongitude, + ), + span: MKCoordinateSpan( + latitudeDelta: style.mapSpanLatitude, + longitudeDelta: style.mapSpanLongitude, + ), + ) + } +} + +/// A selectable region row: the region's emoji + name with a trailing checkmark +/// when picked. +private struct RegionPickerRow: View { + let region: Region + let isSelected: Bool + + @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles + + var body: some View { + let style = regionStyles.style(for: region) + return HStack(spacing: stylesheet.spacing.medium) { + Text(style.emoji) + .accessibilityHidden(true) + Text(region.localizedName) + .foregroundStyle(.primary) + Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark") + .fontWeight(.semibold) + .foregroundStyle(style.tint) + } + } + .contentShape(.rect) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } +} + +#if DEBUG + #Preview("Empty") { + RegionPickerView(model: PrimaryRegionSelectionModel()) + .whereBroadwayRoot() + } + + #Preview("Seeded") { + RegionPickerView(model: PreviewSupport.primaryRegionSelectionModel()) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift new file mode 100644 index 00000000..7eaf9a58 --- /dev/null +++ b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift @@ -0,0 +1,102 @@ +import SwiftUI +import WhereCore + +/// Settings screen for editing your primary regions after onboarding: reuses +/// the same picker and per-region customization the first run uses. Loads the +/// current picks, lets you add/remove (up to the cap) and re-style each, and +/// commits on Save. +struct RegionsSettingsView: View { + @Environment(WhereSession.self) private var session + @Environment(\.dismiss) private var dismiss + + /// The picker/customization model, built once the current picks load. + @State private var model: PrimaryRegionSelectionModel? + @State private var phase: Phase = .pick + @State private var isSaving = false + + private enum Phase: Hashable { + case pick + case customize + } + + private static let logger = WhereLog.channel(.model) + + var body: some View { + NavigationStack { + Group { + if let model { + content(model) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(Strings.regionsManageTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.commonCancel) { dismiss() } + } + } + } + } + } + .task { await loadIfNeeded() } + } + + @ViewBuilder + private func content(_ model: PrimaryRegionSelectionModel) -> some View { + switch phase { + case .pick: + RegionPickerView(model: model) + .navigationTitle(Strings.regionsManageTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.commonCancel) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(Strings.onboardingNext) { phase = .customize } + .disabled(!model.hasSelection) + } + } + case .customize: + // `RegionCustomizeView` supplies its own Back/Done toolbar; Back + // returns to the pick phase, Done saves. + RegionCustomizeView( + model: model, + onBack: { phase = .pick }, + onFinish: { save(model) }, + ) + } + } + + private func loadIfNeeded() async { + guard model == nil else { return } + do { + let existing = try await session.services.primaryRegions() + model = PrimaryRegionSelectionModel(existing: existing) + } catch { + Self.logger.warning("Failed to load primary regions for editing") + // Fall back to an empty picker rather than a stuck spinner. + model = PrimaryRegionSelectionModel() + } + } + + private func save(_ model: PrimaryRegionSelectionModel) { + guard !isSaving else { return } + isSaving = true + Task { + do { + try await model.commit(using: session) + } catch { + Self.logger.warning("Failed to save primary region edits") + } + dismiss() + } + } +} + +#if DEBUG + #Preview { + RegionsSettingsView() + .environment(PreviewSupport.loadedSession()) + .whereBroadwayRoot() + } +#endif diff --git a/Where/WhereUI/Sources/Resources/Localizable.xcstrings b/Where/WhereUI/Sources/Resources/Localizable.xcstrings index 086e6e37..23299d1f 100644 --- a/Where/WhereUI/Sources/Resources/Localizable.xcstrings +++ b/Where/WhereUI/Sources/Resources/Localizable.xcstrings @@ -166,6 +166,19 @@ } } }, + "calendar.day.hasEvidence.accessibility" : { + "comment" : "Accessibility label for a calendar day that has evidence.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%@, has evidence" + } + } + } + }, "calendar.day.needsAttention.accessibility" : { "comment" : "Accessibility label for a calendar day that needs attention.", "extractionState" : "extracted_with_value", @@ -218,6 +231,17 @@ } } }, + "common.cancel" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + } + } + }, "common.day" : { "extractionState" : "manual", "localizations" : { @@ -301,21 +325,717 @@ "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "%1$@: %2$@" + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, + "common.save" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "developer.button.label" : { + "comment" : "Accessibility label for the floating developer button.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer tools" + } + } + } + }, + "developer.close" : { + "comment" : "Accessibility label for closing the developer panel.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + } + } + }, + "developer.collapse" : { + "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exit full screen" + } + } + } + }, + "developer.expand" : { + "comment" : "Accessibility label for growing the developer panel to full screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter full screen" + } + } + } + }, + "developer.footer" : { + "comment" : "Footer for the developer tools list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On-device logs and data tools. Debug builds only." + } + } + } + }, + "developer.inspectorLink" : { + "comment" : "Label for the navigation link that opens the SwiftData inspector.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftData Inspector" + } + } + } + }, + "developer.inspectorTitle" : { + "comment" : "Navigation title for the SwiftData inspector screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftData" + } + } + } + }, + "developer.logsLink" : { + "comment" : "Label for the button that opens the logs screen.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, + "developer.logsTitle" : { + "comment" : "Title of the screen that shows the user's logs.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, + "developer.regionMapLink" : { + "comment" : "Label for the navigation link that opens the region map.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region map" + } + } + } + }, + "developer.title" : { + "comment" : "Navigation title of the developer tools list.", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Developer" + } + } + } + }, + "evidence.add" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add evidence" + } + } + } + }, + "evidence.detail.noAttachment" : { + "comment" : "Label for an evidence item that doesn't have an attached file.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No attachment" + } + } + } + }, + "evidence.detail.noPreview.description" : { + "comment" : "Description of an error message when an attachment can't be previewed.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This attachment can't be previewed here." + } + } + } + }, + "evidence.detail.noPreview.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No preview" + } + } + } + }, + "evidence.detail.noteHeader" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "evidence.detail.previewFailed" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load this attachment." + } + } + } + }, + "evidence.empty.description" : { + "comment" : "Description of the empty state when there is no evidence.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Attach a boarding pass, receipt, or screenshot to back up where you were. Add one here, or share it into Where from another app." + } + } + } + }, + "evidence.empty.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No evidence yet" + } + } + } + }, + "evidence.failed.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load evidence" + } + } + } + }, + "evidence.form.attachmentHeader" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Attachment" + } + } + } + }, + "evidence.form.chooseFile" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose file" + } + } + } + }, + "evidence.form.choosePhoto" : { + "comment" : "Label for choosing a photo from the photo library.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose photo" + } + } + } + }, + "evidence.form.date" : { + "comment" : "Label for the date picker in the evidence form.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Date" + } + } + } + }, + "evidence.form.kind" : { + "comment" : "Label for the \"Kind\" field in the evidence form.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Kind" + } + } + } + }, + "evidence.form.note" : { + "comment" : "Label for the note field in the evidence form.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note" + } + } + } + }, + "evidence.form.notePlaceholder" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add a note (optional)" + } + } + } + }, + "evidence.form.otherLabel" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Label" + } + } + } + }, + "evidence.form.remove" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove attachment" + } + } + } + }, + "evidence.form.save" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + } + } + }, + "evidence.form.saveError.title" : { + "comment" : "Title of an alert when an evidence form fails to save.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't save evidence" + } + } + } + }, + "evidence.kind.boardingPass" : { + "comment" : "\"Boarding pass\" is a generic term for a travel document that shows a passenger's name, destination, and departure/arrival times.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Boarding pass" + } + } + } + }, + "evidence.kind.carRental" : { + "comment" : "The display name for a \"car rental\" evidence kind.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Car rental" + } + } + } + }, + "evidence.kind.document" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Document" + } + } + } + }, + "evidence.kind.email" : { + "comment" : "\"Email\" is a generic term for an", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Email" + } + } + } + }, + "evidence.kind.hotelReceipt" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hotel receipt" + } + } + } + }, + "evidence.kind.other" : { + "comment" : "A generic label for an \"other\" evidence kind.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Other" + } + } + } + }, + "evidence.kind.photo" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Photo" + } + } + } + }, + "evidence.kind.planeTicket" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Plane ticket" + } + } + } + }, + "evidence.kind.rideshare" : { + "comment" : "\"Rideshare\" is a generic term for", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Rideshare" + } + } + } + }, + "evidence.list.title" : { + "comment" : "Title of the evidence list, including the year.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Evidence · %@" + } + } + } + }, + "evidence.row.accessibility" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@, %2$@" + } + } + } + }, + "launch.accessibilityLabel" : { + "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading" + } + } + } + }, + "loggedDays.add" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log a day" + } + } + } + }, + "loggedDays.delete" : { + "comment" : "Label for the destructive button to delete a logged day.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete entry" + } + } + } + }, + "loggedDays.delete.footer" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Removes this manual entry and restores the day's GPS-detected location." + } + } + } + }, + "loggedDays.deleteError.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't delete entry" + } + } + } + }, + "loggedDays.edit.date" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Day" + } + } + } + }, + "loggedDays.edit.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Edit day" + } + } + } + }, + "loggedDays.empty.description" : { + "comment" : "Description of the empty state when there are no logged days.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Backfill a trip the GPS missed, or correct a day by hand — your manual entries for this year show up here." + } + } + } + }, + "loggedDays.empty.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No logged days" + } + } + } + }, + "loggedDays.failed.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Couldn't load logged days" + } + } + } + }, + "loggedDays.filter.all" : { + "comment" : "Label for the \"All\" filter segment in the logged days view.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All" + } + } + } + }, + "loggedDays.filter.label" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Filter" + } + } + } + }, + "loggedDays.kind.logged" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged" + } + } + } + }, + "loggedDays.kind.overridden" : { + "comment" : "Label for a logged-day row that's been manually overridden.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Overridden" + } + } + } + }, + "loggedDays.noMatches.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No days match this filter." + } + } + } + }, + "loggedDays.noMatches.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No matching days" } } } }, - "launch.accessibilityLabel" : { - "comment" : "Spoken by VoiceOver while the launch splash is on screen (the icon and radar animation are decorative and hidden from accessibility).", + "loggedDays.title" : { "extractionState" : "extracted_with_value", - "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Loading" + "value" : "Logged Days · %@" } } } @@ -670,6 +1390,17 @@ } } }, + "onboarding.back" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Back" + } + } + } + }, "onboarding.continue" : { "comment" : "Button text for continuing to the next onboarding step.", "extractionState" : "extracted_with_value", @@ -696,6 +1427,41 @@ } } }, + "onboarding.location.description" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where uses background location to log the regions you pass through. You can change this anytime in Settings." + } + } + } + }, + "onboarding.location.title" : { + "comment" : "Title of a screen in the onboarding flow that asks the user to turn on location.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Turn on location" + } + } + } + }, + "onboarding.next" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Next" + } + } + } + }, "onboarding.notNow" : { "comment" : "Button title for skipping the onboarding flow.", "extractionState" : "extracted_with_value", @@ -735,6 +1501,17 @@ } } }, + "onboarding.regions.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where do you spend your time?" + } + } + } + }, "onboarding.welcome.description" : { "comment" : "A longer description of Where, for the onboarding welcome screen.", "extractionState" : "extracted_with_value", @@ -843,6 +1620,17 @@ } } }, + "primary.evidence" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Evidence" + } + } + } + }, "primary.loading" : { "extractionState" : "manual", "localizations" : { @@ -854,6 +1642,17 @@ } } }, + "primary.loggedDays" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged days" + } + } + } + }, "primary.recentActivity" : { "extractionState" : "manual", "localizations" : { @@ -1162,6 +1961,85 @@ } } }, + "regionCustomize.color" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Color" + } + } + } + }, + "regionCustomize.color.accessibility" : { + "comment" : "Accessibility label for a region color.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Color %@" + } + } + } + }, + "regionCustomize.emoji" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Emoji" + } + } + } + }, + "regionCustomize.step" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Region %1$lld of %2$lld" + } + } + } + }, + "regionCustomize.subtitle" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose a look for %@" + } + } + } + }, + "regionCustomize.symbol" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Icon" + } + } + } + }, + "regionCustomize.title" : { + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Make it yours" + } + } + } + }, "regionMap.empty.description" : { "extractionState" : "manual", "localizations" : { @@ -1173,123 +2051,246 @@ } } }, - "regionMap.empty.title" : { - "extractionState" : "manual", + "regionMap.empty.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No regions" + } + } + } + }, + "regionMap.kind.attribution" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attribution" + } + } + } + }, + "regionMap.kind.attribution.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The simplified polygons the app uses to attribute coordinates today." + } + } + } + }, + "regionMap.kind.picker" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geometry" + } + } + } + }, + "regionMap.kind.source" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source" + } + } + } + }, + "regionMap.kind.source.footer" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity." + } + } + } + }, + "regionMap.legend.header" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Features" + } + } + } + }, + "regionMap.loadError.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't load regions" + } + } + } + }, + "regionMap.map.accessibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Map of region boundaries" + } + } + } + }, + "regionMap.showAll" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show all" + } + } + } + }, + "regionMap.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Region Map" + } + } + } + }, + "regionPicker.empty.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "No regions" + "state" : "new", + "value" : "No matching regions" } } } }, - "regionMap.kind.attribution" : { - "extractionState" : "manual", + "regionPicker.loadError.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Attribution" + "state" : "new", + "value" : "Couldn't load the map" } } } }, - "regionMap.kind.attribution.footer" : { - "extractionState" : "manual", + "regionPicker.map.accessibility" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "The simplified polygons the app uses to attribute coordinates today." + "state" : "new", + "value" : "Map for picking your regions" } } } }, - "regionMap.kind.picker" : { - "extractionState" : "manual", + "regionPicker.mode.list" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Geometry" + "state" : "new", + "value" : "List" } } } }, - "regionMap.kind.source" : { - "extractionState" : "manual", + "regionPicker.mode.map" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Source" + "state" : "new", + "value" : "Map" } } } }, - "regionMap.kind.source.footer" : { - "extractionState" : "manual", + "regionPicker.mode.picker" : { + "comment" : "Accessibility label for the map/list mode switch.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Every feature decoded straight from the bundled GeoJSON, at full authored fidelity." + "state" : "new", + "value" : "View" } } } }, - "regionMap.legend.header" : { - "extractionState" : "manual", + "regionPicker.search.prompt" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Features" + "state" : "new", + "value" : "Search regions" } } } }, - "regionMap.loadError.title" : { - "extractionState" : "manual", + "regionPicker.selectionCount" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Couldn't load regions" + "state" : "new", + "value" : "%1$lld of %2$lld selected" } } } }, - "regionMap.map.accessibility" : { - "extractionState" : "manual", + "regionPicker.subtitle" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Map of region boundaries" + "state" : "new", + "value" : "Pick up to 5 regions where you spend your time." } } } }, - "regionMap.showAll" : { - "extractionState" : "manual", + "regionPicker.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Show all" + "state" : "new", + "value" : "Your regions" } } } }, - "regionMap.title" : { - "extractionState" : "manual", + "regions.manage.title" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Region Map" + "state" : "new", + "value" : "Your regions" } } } @@ -1931,222 +2932,167 @@ } } }, - "developer.button.label" : { - "comment" : "Accessibility label for the floating developer button.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Developer tools" - } - } - } - }, - "developer.close" : { - "comment" : "Accessibility label for closing the developer panel.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Close" - } - } - } - }, - "developer.collapse" : { - "comment" : "Accessibility label for shrinking the developer panel back to a floating window.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Exit full screen" - } - } - } - }, - "developer.expand" : { - "comment" : "Accessibility label for growing the developer panel to full screen.", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter full screen" - } - } - } - }, - "developer.footer" : { - "comment" : "Footer for the developer tools list.", + "settings.issueAlerts.deniedFooter" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "On-device logs and data tools. Debug builds only." + "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." } } } }, - "developer.inspectorLink" : { - "comment" : "Label for the navigation link that opens the SwiftData inspector.", + "settings.issueAlerts.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "SwiftData Inspector" + "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." } } } }, - "developer.inspectorTitle" : { - "comment" : "Navigation title for the SwiftData inspector screen.", + "settings.issueAlerts.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "SwiftData" + "value" : "Issue alerts" } } } }, - "developer.logsLink" : { - "comment" : "Label for the button that opens the logs screen.", + "settings.issueAlerts.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Issue alerts" } } } }, - "developer.logsTitle" : { - "comment" : "Title of the screen that shows the user's logs.", + "settings.location.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Logs" + "value" : "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience." } } } }, - "developer.regionMapLink" : { - "comment" : "Label for the navigation link that opens the region map.", + "settings.location.grant" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Region map" + "value" : "Grant location access" } } } }, - "developer.title" : { - "comment" : "Navigation title of the developer tools list.", + "settings.location.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Developer" + "value" : "Location" } } } }, - "settings.location.footer" : { + "settings.location.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Where watches for visits and big moves to figure out which region you're in. It needs Always access and a little patience." + "value" : "Track in the background" } } } }, - "settings.location.grant" : { + "settings.permissionAlert.message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Grant location access" + "value" : "Where needs Always location access to log which region you're in. You can grant it in the Settings app." } } } }, - "settings.location.header" : { + "settings.permissionAlert.notNow" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Location" + "value" : "Not now" } } } }, - "settings.location.toggle" : { + "settings.permissionAlert.openSettings" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Track in the background" + "value" : "Open Settings" } } } }, - "settings.permissionAlert.message" : { + "settings.permissionAlert.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Where needs Always location access to log which region you're in. You can grant it in the Settings app." + "value" : "Location access needed" } } } }, - "settings.permissionAlert.notNow" : { - "extractionState" : "manual", + "settings.regions.empty" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Not now" + "state" : "new", + "value" : "None yet" } } } }, - "settings.permissionAlert.openSettings" : { - "extractionState" : "manual", + "settings.regions.row" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Open Settings" + "state" : "new", + "value" : "Primary regions" } } } }, - "settings.permissionAlert.title" : { - "extractionState" : "manual", + "settings.regions.section" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Location access needed" + "state" : "new", + "value" : "Regions" } } } @@ -2361,145 +3307,136 @@ } } }, - "settings.issueAlerts.deniedFooter" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Notifications are turned off for Where, so issue alerts can't appear. Turn them on in Settings." - } - } - } - }, - "settings.issueAlerts.footer" : { + "settings.summary.deniedFooter" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Badge the app icon and send a reminder when there are data issues waiting in the Resolve tab." + "value" : "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings." } } } }, - "settings.issueAlerts.header" : { + "settings.summary.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Issue alerts" + "value" : "Get a morning recap of how many days you've logged in each region so far this year." } } } }, - "settings.issueAlerts.toggle" : { + "settings.summary.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Issue alerts" + "value" : "Daily summary" } } } }, - "settings.summary.deniedFooter" : { + "settings.summary.time" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Notifications are turned off for Where, so the daily summary can't appear. Turn them on in Settings." + "value" : "Send at" } } } }, - "settings.summary.footer" : { + "settings.summary.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Get a morning recap of how many days you've logged in each region so far this year." + "value" : "Daily summary" } } } }, - "settings.summary.header" : { + "settings.tabs.footer" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Daily summary" + "value" : "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar." } } } }, - "settings.summary.time" : { + "settings.tabs.header" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Send at" + "value" : "Tabs" } } } }, - "settings.summary.toggle" : { + "settings.tabs.toggle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Daily summary" + "value" : "Hide empty tabs" } } } }, - "settings.tabs.footer" : { + "settings.title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hide the Elsewhere and Resolve tabs while they have nothing to show. Turn this off to always keep them in the tab bar." + "value" : "Settings" } } } }, - "settings.tabs.header" : { - "extractionState" : "manual", + "settings.year.footer" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Tabs" + "state" : "new", + "value" : "Choose which year your reports, calendar, and logged days cover." } } } }, - "settings.tabs.toggle" : { - "extractionState" : "manual", + "settings.year.header" : { + "extractionState" : "extracted_with_value", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Hide empty tabs" + "state" : "new", + "value" : "Report year" } } } }, - "settings.title" : { - "extractionState" : "manual", + "settings.year.label" : { + "comment" : "Label for the year picker in the settings.", + "extractionState" : "extracted_with_value", + "isCommentAutoGenerated" : true, "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Settings" + "state" : "new", + "value" : "Year" } } } diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index a9276d46..938452af 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -111,8 +111,11 @@ public struct RootView: View { } // Seed the Broadway context at the app root so descendants resolve // `WhereStylesheet` (via `@Environment(\.stylesheet)`) against the live - // system traits and the app's themes. - .whereBroadwayRoot() + // system traits and the app's themes, plus the session's live region + // styles (`\.regionStyles`) so cards/calendar/onboarding render the + // user's picked looks. `.default` before the session exists (splash) and + // reactive after, since reading `session.regionStyles` tracks it. + .whereBroadwayRoot(regionStyles: model.session?.regionStyles ?? .default) } /// How the launch splash gives way to the app once the runner is `.ready`: diff --git a/Where/WhereUI/Sources/Settings/SettingsView.swift b/Where/WhereUI/Sources/Settings/SettingsView.swift index 92bbc1b1..56ba3f6f 100644 --- a/Where/WhereUI/Sources/Settings/SettingsView.swift +++ b/Where/WhereUI/Sources/Settings/SettingsView.swift @@ -26,6 +26,7 @@ struct SettingsView: View { @State private var showClearConfirmation = false @State private var showResetConfirmation = false @State private var showAppIcon = false + @State private var showRegions = false // "Find issues now": a manual, force-past-the-throttle data-issue scan and // its result (issue count) shown until the next scan. @@ -61,6 +62,7 @@ struct SettingsView: View { NavigationStack { Form { trackingSection + regionsSection remindersSection summarySection issueAlertsSection @@ -80,6 +82,9 @@ struct SettingsView: View { .sheet(isPresented: $showAppIcon) { AppIconView() } + .sheet(isPresented: $showRegions) { + RegionsSettingsView() + } .alert(Strings.settingsPermissionAlertTitle, isPresented: $session.permissionDenied) { Button(Strings.settingsPermissionAlertOpenSettings) { openSystemSettings() } Button(Strings.settingsPermissionAlertNotNow, role: .cancel) {} @@ -132,6 +137,22 @@ struct SettingsView: View { } } + private var regionsSection: some View { + Section { + Button { + showRegions = true + } label: { + LabeledContent { + Text(Strings.settingsRegionsRow) + .foregroundStyle(.secondary) + } label: { + Label(Strings.settingsRegionsSection, systemImage: "map.fill") + } + } + .tint(.primary) + } + } + private var trackingSection: some View { @Bindable var session = session return Section { diff --git a/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift b/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift index be954345..8312eb40 100644 --- a/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift +++ b/Where/WhereUI/Sources/Shared/Coordinate+MapKit.swift @@ -19,3 +19,15 @@ extension Sequence { map(\.clLocationCoordinate) } } + +extension Coordinate { + /// Build a model `Coordinate` from a MapKit/CoreLocation coordinate — the + /// reverse of ``clLocationCoordinate``, used to hit-test a map tap against + /// `RegionAttributor` (the region picker's map mode). + init(_ clLocationCoordinate: CLLocationCoordinate2D) { + self.init( + latitude: clLocationCoordinate.latitude, + longitude: clLocationCoordinate.longitude, + ) + } +} diff --git a/Where/WhereUI/Sources/Shared/RecordedPointsMap.swift b/Where/WhereUI/Sources/Shared/RecordedPointsMap.swift index 4a00a99d..7300cd11 100644 --- a/Where/WhereUI/Sources/Shared/RecordedPointsMap.swift +++ b/Where/WhereUI/Sources/Shared/RecordedPointsMap.swift @@ -24,6 +24,7 @@ struct RecordedPointsMap: View { let points: [RecordedMapPoint] @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var regionMap: WhereStylesheet.RegionMapStyle { stylesheet.regionMap @@ -32,17 +33,17 @@ struct RecordedPointsMap: View { var body: some View { Map(initialPosition: .automatic) { ForEach(pins) { pin in + let tint = regionStyles.style(for: pin.region).tint if let radius = drawnUncertaintyRadius(for: pin) { MapCircle(center: pin.coordinate, radius: radius) - .foregroundStyle(pin.region.style.tint - .opacity(regionMap.uncertaintyFillOpacity)) + .foregroundStyle(tint.opacity(regionMap.uncertaintyFillOpacity)) .stroke( - pin.region.style.tint.opacity(regionMap.uncertaintyStrokeOpacity), + tint.opacity(regionMap.uncertaintyStrokeOpacity), lineWidth: regionMap.uncertaintyStrokeWidth, ) } Marker("", coordinate: pin.coordinate) - .tint(pin.region.style.tint) + .tint(tint) } } .mapStyle(.standard(pointsOfInterest: .excludingAll)) diff --git a/Where/WhereUI/Sources/Shared/RegionSelectionState.swift b/Where/WhereUI/Sources/Shared/RegionSelectionState.swift index e54beb7f..cf9afbd8 100644 --- a/Where/WhereUI/Sources/Shared/RegionSelectionState.swift +++ b/Where/WhereUI/Sources/Shared/RegionSelectionState.swift @@ -21,14 +21,33 @@ final class RegionToggleItem: Identifiable { /// Bindable region toggles for manual-day and relabel forms. Each region is an /// `@Observable` row so `Toggle` can bind with `$item.isOn` instead of a /// closure-based `Binding`. +/// +/// The full catalog is a long list, so the forms group it: the user's **tracked** +/// regions first, then any **non-tracked regions used this year**, then +/// **everything else** (collapsed). Membership is stable while the form is open — +/// it keys off the tracked set + the year's used regions (loaded once via +/// ``applyGrouping(tracked:usedThisYear:)``), so toggling a row never makes it +/// jump sections. Until that loads, ``trackedRegions`` is `nil` and callers fall +/// back to the flat ``items`` list. `.other` (the catch-all) always stays in the +/// "everything else" group, never the used-this-year one. @Observable final class RegionSelectionState { var items: [RegionToggleItem] + /// The user's tracked regions, loaded asynchronously by the form. `nil` + /// until loaded — callers render the flat `items` list meanwhile. + private(set) var trackedRegions: Set? + + /// Tracked regions in pick order, for the tracked group's row order. + private var trackedOrder: [Region] = [] + + /// Regions with days in the selected year (`.other` excluded), so a + /// non-tracked place the user has been this year is easy to reach. + private var usedThisYear: Set = [] + /// - Parameters: /// - regions: the regions to offer as toggles, in order. Defaults to every - /// available catalog region plus the `.other` catch-all; a caller (e.g. a - /// future onboarding-aware form) can pass the user's tracked subset. + /// available catalog region plus the `.other` catch-all. /// - selectedRegions: which of those start on. init( regions: [Region] = RegionCatalog.shared.all + [.other], @@ -42,6 +61,43 @@ final class RegionSelectionState { var selectedRegions: Set { Set(items.filter(\.isOn).map(\.region)) } + + /// Record the tracked/primary regions (pick order) and the regions used in + /// the selected year so the form can group the toggles. The form loads once. + func applyGrouping(tracked: [PrimaryRegion], usedThisYear: Set) { + trackedOrder = tracked.map(\.region) + trackedRegions = Set(trackedOrder) + self.usedThisYear = usedThisYear + } + + /// The tracked regions' rows, in pick order. Empty until grouping loads. + var trackedItems: [RegionToggleItem] { + guard trackedRegions != nil else { return [] } + let byRegion = Dictionary( + items.map { ($0.region, $0) }, + uniquingKeysWith: { first, _ in first }, + ) + return trackedOrder.compactMap { byRegion[$0] } + } + + /// Rows for non-tracked regions used this year (never `.other`). Empty until + /// grouping loads. + var usedItems: [RegionToggleItem] { + guard let tracked = trackedRegions else { return [] } + return items.filter { isUsedThisYear($0.region, tracked: tracked) } + } + + /// Every remaining row — neither tracked nor used-this-year (plus `.other`). + /// Falls back to the full list until grouping loads. + var otherItems: [RegionToggleItem] { + guard let tracked = trackedRegions else { return items } + return items + .filter { !tracked.contains($0.region) && !isUsedThisYear($0.region, tracked: tracked) } + } + + private func isUsedThisYear(_ region: Region, tracked: Set) -> Bool { + region != .other && !tracked.contains(region) && usedThisYear.contains(region) + } } /// Drives save-error alerts on manual-day forms. The message is the source of diff --git a/Where/WhereUI/Sources/Shared/RegionToggleRow.swift b/Where/WhereUI/Sources/Shared/RegionToggleRow.swift index bfaeb5a0..4823bba4 100644 --- a/Where/WhereUI/Sources/Shared/RegionToggleRow.swift +++ b/Where/WhereUI/Sources/Shared/RegionToggleRow.swift @@ -5,12 +5,14 @@ import WhereCore struct RegionToggleRow: View { @Bindable var item: RegionToggleItem + @Environment(\.regionStyles) private var regionStyles + var body: some View { Toggle(isOn: $item.isOn) { Label { Text(item.region.localizedName) } icon: { - Text(item.region.style.emoji) + Text(regionStyles.style(for: item.region).emoji) } } } diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index 99dbf680..aa6a636a 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -332,6 +332,206 @@ enum Strings { String(localized: "onboarding.notNow", defaultValue: "Not Now", bundle: .module) } + static var onboardingBack: String { + String(localized: "onboarding.back", defaultValue: "Back", bundle: .module) + } + + static var onboardingNext: String { + String(localized: "onboarding.next", defaultValue: "Next", bundle: .module) + } + + static var onboardingRestoreBackup: String { + String( + localized: "onboarding.restoreBackup", + defaultValue: "Restore from a backup", + bundle: .module, + ) + } + + static var onboardingRestoreErrorTitle: String { + String( + localized: "onboarding.restoreError.title", + defaultValue: "Couldn't restore backup", + bundle: .module, + ) + } + + static var onboardingRestoring: String { + String(localized: "onboarding.restoring", defaultValue: "Restoring…", bundle: .module) + } + + static var onboardingRegionsTitle: String { + String( + localized: "onboarding.regions.title", + defaultValue: "Where do you spend your time?", + bundle: .module, + ) + } + + static var onboardingLocationTitle: String { + String( + localized: "onboarding.location.title", + defaultValue: "Turn on location", + bundle: .module, + ) + } + + static var onboardingLocationDescription: String { + String( + localized: "onboarding.location.description", + defaultValue: "Where uses background location to log the regions you pass through. You can change this anytime in Settings.", + bundle: .module, + ) + } + + // MARK: Region picker + + static var regionPickerTitle: String { + String(localized: "regionPicker.title", defaultValue: "Your regions", bundle: .module) + } + + static var regionPickerSubtitle: String { + String( + localized: "regionPicker.subtitle", + defaultValue: "Pick up to 5 regions where you spend your time.", + bundle: .module, + ) + } + + static var regionPickerModeMap: String { + String(localized: "regionPicker.mode.map", defaultValue: "Map", bundle: .module) + } + + static var regionPickerModeList: String { + String(localized: "regionPicker.mode.list", defaultValue: "List", bundle: .module) + } + + /// Accessibility label for the map/list mode switch. + static var regionPickerModePicker: String { + String(localized: "regionPicker.mode.picker", defaultValue: "View", bundle: .module) + } + + static var regionPickerSearchPrompt: String { + String( + localized: "regionPicker.search.prompt", + defaultValue: "Search regions", + bundle: .module, + ) + } + + /// "2 of 5 selected". + static func regionPickerSelectionCount(selected: Int, max: Int) -> String { + String( + localized: "regionPicker.selectionCount", + defaultValue: "\(selected) of \(max) selected", + bundle: .module, + ) + } + + /// Shown when the selection is full, so an ignored tap on a new region reads + /// as "at capacity" rather than "unresponsive". + static func regionPickerAtCapacity(max: Int) -> String { + String( + localized: "regionPicker.atCapacity", + defaultValue: "That's the maximum of \(max) — deselect one to choose another.", + bundle: .module, + ) + } + + static var regionPickerMapAccessibility: String { + String( + localized: "regionPicker.map.accessibility", + defaultValue: "Map for picking your regions", + bundle: .module, + ) + } + + static var regionPickerEmptyTitle: String { + String( + localized: "regionPicker.empty.title", + defaultValue: "No matching regions", + bundle: .module, + ) + } + + static var regionPickerLoadErrorTitle: String { + String( + localized: "regionPicker.loadError.title", + defaultValue: "Couldn't load the map", + bundle: .module, + ) + } + + // MARK: Region customization + + static var regionCustomizeTitle: String { + String(localized: "regionCustomize.title", defaultValue: "Make it yours", bundle: .module) + } + + /// "Choose a look for California". + static func regionCustomizeSubtitle(region: String) -> String { + String( + localized: "regionCustomize.subtitle", + defaultValue: "Choose a look for \(region)", + bundle: .module, + ) + } + + static var regionCustomizeColor: String { + String(localized: "regionCustomize.color", defaultValue: "Color", bundle: .module) + } + + static var regionCustomizeEmoji: String { + String(localized: "regionCustomize.emoji", defaultValue: "Emoji", bundle: .module) + } + + static var regionCustomizeSymbol: String { + String(localized: "regionCustomize.symbol", defaultValue: "Icon", bundle: .module) + } + + /// "Region 2 of 5". + static func regionCustomizeStep(current: Int, total: Int) -> String { + String( + localized: "regionCustomize.step", + defaultValue: "Region \(current) of \(total)", + bundle: .module, + ) + } + + static func regionColorAccessibility(_ token: RegionColorToken) -> String { + String( + localized: "regionCustomize.color.accessibility", + defaultValue: "Color \(token.rawValue)", + bundle: .module, + ) + } + + // MARK: Region management (Settings) + + static var regionsManageTitle: String { + String(localized: "regions.manage.title", defaultValue: "Your regions", bundle: .module) + } + + static var settingsRegionsSection: String { + String(localized: "settings.regions.section", defaultValue: "Regions", bundle: .module) + } + + static var settingsRegionsRow: String { + String( + localized: "settings.regions.row", + defaultValue: "Primary regions", + bundle: .module, + ) + } + + static var settingsRegionsEmpty: String { + String(localized: "settings.regions.empty", defaultValue: "None yet", bundle: .module) + } + + static var commonSave: String { + String(localized: "common.save", defaultValue: "Save", bundle: .module) + } + // MARK: Migration static var migrationTitle: String { @@ -925,6 +1125,30 @@ enum Strings { localized("manual.regions.footer") } + static var manualRegionsTrackedHeader: String { + String( + localized: "manual.regions.tracked.header", + defaultValue: "Your regions", + bundle: .module, + ) + } + + static var manualRegionsUsedHeader: String { + String( + localized: "manual.regions.used.header", + defaultValue: "Already this year", + bundle: .module, + ) + } + + static var manualRegionsMore: String { + String( + localized: "manual.regions.more", + defaultValue: "More regions", + bundle: .module, + ) + } + static var manualTitle: String { localized("manual.title") } diff --git a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift index 033cf336..56f1fefa 100644 --- a/Where/WhereUI/Sources/Shared/WhereStylesheet.swift +++ b/Where/WhereUI/Sources/Shared/WhereStylesheet.swift @@ -19,6 +19,7 @@ struct WhereStylesheet: BStylesheet { var appIcon = AppIconStyle.standard var timeline = TimelineStyle.standard var regionMap = RegionMapStyle.standard + var regionPicker = RegionPickerStyle.standard var evidence = EvidenceStyle.standard var palette = Palette.standard var motion = Motion.standard @@ -455,6 +456,69 @@ extension WhereStylesheet { } } +// MARK: - Region picker / customization + +extension WhereStylesheet { + /// Style for the primary-region picker (`RegionPickerView`) and per-region + /// customization (`RegionAppearanceEditor`): the selectable-state map fills, + /// the color swatch and emoji/symbol tile geometry, and the default US map + /// framing. Generic spacing (grid gaps, section stacks) still comes from + /// ``Spacing``. The camera is stored as raw degrees so the stylesheet stays + /// MapKit-free; the view assembles the `MKCoordinateRegion`. + struct RegionPickerStyle: Equatable { + /// Corner radius of the map's rounded container. + var mapCornerRadius: CGFloat + /// Polygon fill opacity for a selected vs unselected state. + var selectedFillOpacity: Double + var unselectedFillOpacity: Double + /// Polygon stroke opacity + width for a selected vs unselected state. + var selectedStrokeOpacity: Double + var unselectedStrokeOpacity: Double + var selectedStrokeWidth: CGFloat + var unselectedStrokeWidth: CGFloat + /// Diameter of a color swatch and the width of its selection ring. + var colorSwatchSize: CGFloat + var colorSwatchSelectionRing: CGFloat + /// Minimum grid cell width for the color swatches. + var colorSwatchMinWidth: CGFloat + /// Edge of an emoji/symbol tile and its minimum grid cell width. + var glyphTileSize: CGFloat + var glyphTileMinWidth: CGFloat + /// Corner radius and selection stroke width of a glyph tile. + var glyphCornerRadius: CGFloat + var glyphSelectionStrokeWidth: CGFloat + /// Background tint opacity of a selected glyph tile. + var glyphSelectedBackgroundOpacity: Double + /// Default map camera framing the contiguous US (raw degrees). + var mapCenterLatitude: Double + var mapCenterLongitude: Double + var mapSpanLatitude: Double + var mapSpanLongitude: Double + + static let standard = RegionPickerStyle( + mapCornerRadius: 12, + selectedFillOpacity: 0.55, + unselectedFillOpacity: 0.12, + selectedStrokeOpacity: 0.9, + unselectedStrokeOpacity: 0.35, + selectedStrokeWidth: 2, + unselectedStrokeWidth: 1, + colorSwatchSize: 40, + colorSwatchSelectionRing: 3, + colorSwatchMinWidth: 44, + glyphTileSize: 48, + glyphTileMinWidth: 52, + glyphCornerRadius: 6, + glyphSelectionStrokeWidth: 2, + glyphSelectedBackgroundOpacity: 0.2, + mapCenterLatitude: 39.5, + mapCenterLongitude: -98.35, + mapSpanLatitude: 45, + mapSpanLongitude: 55, + ) + } +} + // MARK: - Evidence extension WhereStylesheet { @@ -640,8 +704,17 @@ extension View { /// directly — it already gets it through `WhereUI` (a dynamic framework), and /// a second copy would split Broadway's type-keyed environment metadata (see /// the root `AGENTS.md` "Targets" note). - public func whereBroadwayRoot() -> some View { + /// + /// Also seeds `\.regionStyles` so descendants resolve per-region looks + /// (`region` cards, calendar dots, widgets, snippets) from one place. The app + /// passes `WhereSession`'s live resolver, the widget process one built from + /// its `WidgetSnapshot`, and intents one from their services; the default + /// empty resolver yields fallback looks (previews, the region-map viewer). + public func whereBroadwayRoot( + regionStyles: RegionStyleResolver = .default, + ) -> some View { broadwayRoot(themes: WhereThemes.current) + .environment(\.regionStyles, regionStyles) } } diff --git a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift index 70793e63..5cdcb2d8 100644 --- a/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift +++ b/Where/WhereUI/Sources/Widgets/TodayAccessoryViews.swift @@ -13,6 +13,8 @@ public struct TodayInlineAccessoryView: View { self.snapshot = snapshot } + @Environment(\.regionStyles) private var regionStyles + private var regions: [Region] { snapshot.orderedDayRegions } @@ -21,7 +23,7 @@ public struct TodayInlineAccessoryView: View { if let first = regions.first { Label( regions.map(\.localizedName).joined(separator: " · "), - systemImage: first.style.symbolName, + systemImage: regionStyles.style(for: first).symbolName, ) } else { Label(Strings.widgetTodayEmpty, systemImage: "location.slash") @@ -39,6 +41,8 @@ public struct TodayCircularAccessoryView: View { self.snapshot = snapshot } + @Environment(\.regionStyles) private var regionStyles + private var regions: [Region] { snapshot.orderedDayRegions } @@ -47,8 +51,11 @@ public struct TodayCircularAccessoryView: View { ZStack { AccessoryWidgetBackground() VStack(spacing: 0) { - Image(systemName: regions.first?.style.symbolName ?? "location.slash") - .font(.title3) + Image( + systemName: regions.first + .map { regionStyles.style(for: $0).symbolName } ?? "location.slash", + ) + .font(.title3) if regions.count > 1 { Text(verbatim: "+\(regions.count - 1)") .font(.caption2.weight(.semibold)) diff --git a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift index 73dd426b..69861269 100644 --- a/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/TodayWidgetView.swift @@ -16,6 +16,7 @@ public struct TodayWidgetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var regions: [Region] { snapshot.orderedDayRegions @@ -54,15 +55,16 @@ public struct TodayWidgetView: View { /// The common case — one region so far today — gets the full passport /// treatment: big emoji, serif uppercase name in the region's tint. private func heroRegion(_ region: Region) -> some View { - VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { - Text(region.style.emoji) + let style = regionStyles.style(for: region) + return VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { + Text(style.emoji) .font(.largeTitle) .accessibilityHidden(true) Text(region.localizedName) .font(stylesheet.typography.widgetHeroRegion) .textCase(.uppercase) .tracking(1) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) .lineLimit(2) .minimumScaleFactor(0.6) } @@ -72,13 +74,14 @@ public struct TodayWidgetView: View { private var regionList: some View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { ForEach(regions, id: \.self) { region in + let style = regionStyles.style(for: region) HStack(spacing: stylesheet.spacing.small) { - Text(region.style.emoji) + Text(style.emoji) .font(.caption) .accessibilityHidden(true) Text(region.localizedName) .font(.caption.weight(.semibold)) - .foregroundStyle(region.style.tint) + .foregroundStyle(style.tint) .lineLimit(1) .minimumScaleFactor(0.7) } diff --git a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift index 8af74e24..0aede5cf 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsRectangularAccessoryView.swift @@ -15,6 +15,7 @@ public struct YearTotalsRectangularAccessoryView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var ranked: [RegionDays] { snapshot.rankedTotals(maxRows: Self.maxRows) @@ -28,7 +29,7 @@ public struct YearTotalsRectangularAccessoryView: View { VStack(alignment: .leading, spacing: 0) { ForEach(ranked) { entry in HStack(spacing: stylesheet.spacing.xSmall) { - Image(systemName: entry.region.style.symbolName) + Image(systemName: regionStyles.style(for: entry.region).symbolName) .font(.caption2) .accessibilityHidden(true) Text(entry.region.localizedName) diff --git a/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift b/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift index f447c6df..e661b8aa 100644 --- a/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift +++ b/Where/WhereUI/Sources/Widgets/YearTotalsWidgetView.swift @@ -17,6 +17,7 @@ public struct YearTotalsWidgetView: View { } @Environment(\.stylesheet) private var stylesheet + @Environment(\.regionStyles) private var regionStyles private var ranked: [RegionDays] { snapshot.rankedTotals(maxRows: maxRows) @@ -47,7 +48,7 @@ public struct YearTotalsWidgetView: View { VStack(alignment: .leading, spacing: stylesheet.spacing.xSmall) { ForEach(ranked) { entry in HStack(spacing: stylesheet.spacing.small) { - Text(entry.region.style.emoji) + Text(regionStyles.style(for: entry.region).emoji) .font(.caption) .accessibilityHidden(true) Text(entry.region.localizedName) @@ -58,7 +59,7 @@ public struct YearTotalsWidgetView: View { Text(entry.days, format: .number) .font(stylesheet.typography.widgetTotalNumber) .monospacedDigit() - .foregroundStyle(entry.region.style.tint) + .foregroundStyle(regionStyles.style(for: entry.region).tint) } .accessibilityElement(children: .combine) .accessibilityLabel( diff --git a/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift new file mode 100644 index 00000000..cf78f41c --- /dev/null +++ b/Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift @@ -0,0 +1,131 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// The primary-region picker model: US-only options, the pick cap, ordered +/// selection, and appearance drafts. +@MainActor +struct PrimaryRegionSelectionModelTests { + private func region(_ id: String) throws -> Region { + try #require(Region(rawValue: id)) + } + + @Test func offersUSRegionsOnly() { + let model = PrimaryRegionSelectionModel() + #expect(!model.available.isEmpty) + #expect(model.available.allSatisfy { $0.rawValue.hasPrefix("us-") }) + } + + @Test func toggleAddsAndRemovesPreservingOrder() throws { + let model = PrimaryRegionSelectionModel() + let tx = try region("us-TX") + model.toggle(.california) + model.toggle(.newYork) + model.toggle(tx) + #expect(model.selectedRegions == [.california, .newYork, tx]) + + model.toggle(.newYork) + #expect(model.selectedRegions == [.california, tx]) + } + + @Test func cannotSelectMoreThanTheCap() { + let model = PrimaryRegionSelectionModel() + // Pick the first maxSelection available regions, then attempt one more. + let picks = Array(model.available.prefix(PrimaryRegionSelectionModel.maxSelection)) + for region in picks { + model.toggle(region) + } + #expect(model.isAtCapacity) + + let extra = model.available[PrimaryRegionSelectionModel.maxSelection] + #expect(!model.canToggle(extra)) + model.toggle(extra) + // The add past the cap is a no-op. + #expect(model.selectionCount == PrimaryRegionSelectionModel.maxSelection) + #expect(!model.isSelected(extra)) + + // An already-selected region can still be toggled off at capacity. + #expect(model.canToggle(picks[0])) + } + + @Test func appearanceDraftsDefaultThenUpdate() { + let model = PrimaryRegionSelectionModel() + let start = model.appearance(for: .california) + #expect(start == RegionAppearanceCatalog.defaultAppearance(for: .california)) + + model.setColor(.mint, for: .california) + model.setEmoji("🌊", for: .california) + model.setSymbol("water.waves", for: .california) + let edited = model.appearance(for: .california) + #expect(edited.color == .mint) + #expect(edited.emoji == "🌊") + #expect(edited.symbolName == "water.waves") + } + + @Test func seedsFromExistingPrimaryRegions() throws { + let tx = try region("us-TX") + let look = RegionAppearance(color: .orange, emoji: "🤠", symbolName: "star.fill") + let model = PrimaryRegionSelectionModel(existing: [ + PrimaryRegion(region: .california, appearance: nil, order: 0), + PrimaryRegion(region: tx, appearance: look, order: 1), + ]) + #expect(model.selectedRegions == [.california, tx]) + #expect(model.appearance(for: tx) == look) + } + + @Test func commitPersistsPicksAsTrackedRegionsWithAppearance() async throws { + let session = PreviewSupport.loadedSession() + let model = PrimaryRegionSelectionModel() + model.toggle(.california) + model.setColor(.orange, for: .california) + model.setEmoji("🌴", for: .california) + + try await model.commit(using: session) + + let primary = try await session.services.primaryRegions() + #expect(primary.map(\.region) == [.california]) + #expect(primary.first?.appearance?.emoji == "🌴") + #expect(try await session.services.trackedRegions() == [.california]) + } + + @Test func editingTheDefaultSetConvergesToUSOnly() async throws { + // A fresh install has no stored rows, so `primaryRegions()` returns the + // legacy default set (CA / NY / Canada / EU). Opening the editor drops + // the non-US regions from the selection, and saving unchanged removes + // them — the app is US-only now (documented behavior, not a bug). + let session = PreviewSupport.loadedSession() + let existing = try await session.services.primaryRegions() + #expect(Set(existing.map(\.region)) == SwiftDataStore.defaultTrackedRegions) + + let model = PrimaryRegionSelectionModel(existing: existing) + #expect(Set(model.selectedRegions) == [.california, .newYork]) + + try await model.commit(using: session) + #expect(try await session.services.trackedRegions() == [.california, .newYork]) + } + + @Test func commitUntracksRemovedRegions() async throws { + let session = PreviewSupport.loadedSession() + // Seed the store with two picks, then commit an edit that drops one. + try await session.services.setPrimaryRegions([ + PrimaryRegion( + region: .california, + appearance: RegionAppearanceCatalog.defaultAppearance(for: .california), + order: 0, + ), + PrimaryRegion( + region: .newYork, + appearance: RegionAppearanceCatalog.defaultAppearance(for: .newYork), + order: 1, + ), + ]) + + let existing = try await session.services.primaryRegions() + let model = PrimaryRegionSelectionModel(existing: existing) + model.toggle(.newYork) // remove NY + try await model.commit(using: session) + + #expect(try await session.services.trackedRegions() == [.california]) + } +} diff --git a/Where/WhereUI/Tests/RegionSelectionStateTests.swift b/Where/WhereUI/Tests/RegionSelectionStateTests.swift new file mode 100644 index 00000000..90495b59 --- /dev/null +++ b/Where/WhereUI/Tests/RegionSelectionStateTests.swift @@ -0,0 +1,79 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// The manual-day region toggles group into tracked / used-this-year / +/// everything-else once grouping loads; `.other` always stays in the last group, +/// and membership stays stable as rows are toggled. +struct RegionSelectionStateTests { + private func region(_ id: String) throws -> Region { + try #require(Region(rawValue: id)) + } + + private func primary(_ regions: [Region]) -> [PrimaryRegion] { + regions.enumerated().map { PrimaryRegion(region: $1, appearance: nil, order: $0) } + } + + @Test func flatUntilGroupingLoads() throws { + let texas = try region("us-TX") + let state = RegionSelectionState( + regions: [.california, .newYork, texas, .other], + selectedRegions: [.newYork], + ) + #expect(state.trackedRegions == nil) + #expect(state.otherItems.map(\.region) == [.california, .newYork, texas, .other]) + #expect(state.trackedItems.isEmpty) + #expect(state.usedItems.isEmpty) + } + + @Test func partitionsIntoTrackedUsedThisYearAndEverythingElse() throws { + let texas = try region("us-TX") + let florida = try region("us-FL") + let state = RegionSelectionState( + regions: [.california, .newYork, texas, florida, .other], + selectedRegions: [], + ) + // Used this year: TX (non-tracked) and .other — .other is excluded from + // the used group by design. + state.applyGrouping( + tracked: primary([.california, .newYork]), + usedThisYear: [texas, .other], + ) + + #expect(state.trackedItems.map(\.region) == [.california, .newYork]) + #expect(state.usedItems.map(\.region) == [texas]) + // FL (never used) and .other (always here) fall to everything-else. + #expect(state.otherItems.map(\.region) == [florida, .other]) + } + + @Test func trackedTakesPrecedenceOverUsedThisYear() throws { + let texas = try region("us-TX") + let state = RegionSelectionState( + regions: [.california, texas, .other], + selectedRegions: [], + ) + // California is tracked *and* used this year → tracked, not used. + state.applyGrouping(tracked: primary([.california]), usedThisYear: [.california, texas]) + #expect(state.trackedItems.map(\.region) == [.california]) + #expect(state.usedItems.map(\.region) == [texas]) + } + + @Test func togglingDoesNotChangeSectionMembership() throws { + let texas = try region("us-TX") + let florida = try region("us-FL") + let state = RegionSelectionState( + regions: [.california, texas, florida, .other], + selectedRegions: [], + ) + state.applyGrouping(tracked: primary([.california]), usedThisYear: [texas]) + #expect(state.otherItems.map(\.region) == [florida, .other]) + + // Turn on a row in everything-else — it stays there, and the overall + // selection reflects it. + let floridaItem = try #require(state.otherItems.first { $0.region == florida }) + floridaItem.isOn = true + #expect(state.otherItems.map(\.region) == [florida, .other]) + #expect(state.selectedRegions == [florida]) + } +} diff --git a/Where/WhereUI/Tests/RegionStyleResolverTests.swift b/Where/WhereUI/Tests/RegionStyleResolverTests.swift new file mode 100644 index 00000000..de6519f6 --- /dev/null +++ b/Where/WhereUI/Tests/RegionStyleResolverTests.swift @@ -0,0 +1,54 @@ +import RegionKit +import Testing +@testable import WhereCore +@testable import WhereUI + +/// `RegionStyleResolver.style(for:)` resolves a picked appearance, falling back +/// to the deterministic default look for regions the user hasn't customized. +struct RegionStyleResolverTests { + @Test func pickedAppearanceWinsOverFallback() { + let look = RegionAppearance(color: .pink, emoji: "🎸", symbolName: "star.fill") + let resolver = RegionStyleResolver(appearances: [.california: look]) + + let california = resolver.style(for: .california) + #expect(california.emoji == "🎸") + #expect(california.symbolName == "star.fill") + #expect(california.tint == RegionColorToken.pink.color) + } + + @Test func unpickedRegionFallsBackToDefault() { + let resolver = RegionStyleResolver(appearances: [:]) + let expected = RegionStyle.fallbackStyle(for: .newYork) + let resolved = resolver.style(for: .newYork) + #expect(resolved.emoji == expected.emoji) + #expect(resolved.symbolName == expected.symbolName) + } + + @Test func buildsFromPrimaryRegionsKeepingOnlyResolvedLooks() { + let look = RegionAppearance(color: .teal, emoji: "🌊", symbolName: "water.waves") + let resolver = RegionStyleResolver(primaryRegions: [ + PrimaryRegion(region: .california, appearance: look, order: 0), + PrimaryRegion(region: .newYork, appearance: nil, order: 1), + ]) + + #expect(resolver.style(for: .california).emoji == "🌊") + // NY had no appearance → resolves to its fallback, not California's look. + #expect(resolver.style(for: .newYork).emoji == RegionStyle.fallbackStyle(for: .newYork) + .emoji) + } + + @Test func defaultResolverIsAllFallback() { + let resolver = RegionStyleResolver.default + let expected = RegionStyle.fallbackStyle(for: .california) + #expect(resolver.style(for: .california).symbolName == expected.symbolName) + } + + @Test func defaultAppearanceMatchesSharedCatalog() { + // The customization pre-fill and the fallback share one source, so a + // region with no pick renders its catalog default. + let expected = RegionAppearanceCatalog.defaultAppearance(for: .california) + let fallback = RegionStyle.fallbackStyle(for: .california) + #expect(fallback.emoji == expected.emoji) + #expect(fallback.symbolName == expected.symbolName) + } +} diff --git a/Where/WhereUI/Tests/WhereStylesheetTests.swift b/Where/WhereUI/Tests/WhereStylesheetTests.swift index d8bafb83..2ad9c6b0 100644 --- a/Where/WhereUI/Tests/WhereStylesheetTests.swift +++ b/Where/WhereUI/Tests/WhereStylesheetTests.swift @@ -173,6 +173,29 @@ struct WhereStylesheetTests { #expect(regionMap.uncertaintyStrokeWidth == 1) } + @Test func regionPickerStyle() { + let picker = style.regionPicker + #expect(picker.mapCornerRadius == 12) + #expect(picker.selectedFillOpacity == 0.55) + #expect(picker.unselectedFillOpacity == 0.12) + #expect(picker.selectedStrokeOpacity == 0.9) + #expect(picker.unselectedStrokeOpacity == 0.35) + #expect(picker.selectedStrokeWidth == 2) + #expect(picker.unselectedStrokeWidth == 1) + #expect(picker.colorSwatchSize == 40) + #expect(picker.colorSwatchSelectionRing == 3) + #expect(picker.colorSwatchMinWidth == 44) + #expect(picker.glyphTileSize == 48) + #expect(picker.glyphTileMinWidth == 52) + #expect(picker.glyphCornerRadius == 6) + #expect(picker.glyphSelectionStrokeWidth == 2) + #expect(picker.glyphSelectedBackgroundOpacity == 0.2) + #expect(picker.mapCenterLatitude == 39.5) + #expect(picker.mapCenterLongitude == -98.35) + #expect(picker.mapSpanLatitude == 45) + #expect(picker.mapSpanLongitude == 55) + } + @Test func evidenceStyle() { let evidence = style.evidence #expect(evidence.previewCornerRadius == 22) diff --git a/Where/WhereWidgets/Sources/TodayWidget.swift b/Where/WhereWidgets/Sources/TodayWidget.swift index 2d768715..e1af9dd3 100644 --- a/Where/WhereWidgets/Sources/TodayWidget.swift +++ b/Where/WhereWidgets/Sources/TodayWidget.swift @@ -15,8 +15,11 @@ struct TodayWidget: Widget { // Seed the Broadway context so the shared WhereUI content views // resolve trait-aware `@Environment(\.stylesheet)` tokens instead // of falling back to `WhereStylesheet.default` (the extension has - // no other Broadway root). - .whereBroadwayRoot() + // no other Broadway root), plus the region looks the snapshot + // carries so `\.regionStyles` renders the user's picks. + .whereBroadwayRoot( + regionStyles: RegionStyleResolver(appearances: entry.snapshot.appearances), + ) } .configurationDisplayName(WidgetStrings.todayGalleryName) .description(WidgetStrings.todayGalleryDescription) diff --git a/Where/WhereWidgets/Sources/YearTotalsWidget.swift b/Where/WhereWidgets/Sources/YearTotalsWidget.swift index 361c0339..d4249655 100644 --- a/Where/WhereWidgets/Sources/YearTotalsWidget.swift +++ b/Where/WhereWidgets/Sources/YearTotalsWidget.swift @@ -15,8 +15,11 @@ struct YearTotalsWidget: Widget { // Seed the Broadway context so the shared WhereUI content views // resolve trait-aware `@Environment(\.stylesheet)` tokens instead // of falling back to `WhereStylesheet.default` (the extension has - // no other Broadway root). - .whereBroadwayRoot() + // no other Broadway root), plus the region looks the snapshot + // carries so `\.regionStyles` renders the user's picks. + .whereBroadwayRoot( + regionStyles: RegionStyleResolver(appearances: entry.snapshot.appearances), + ) } .configurationDisplayName(WidgetStrings.yearTotalsGalleryName) .description(WidgetStrings.yearTotalsGalleryDescription)