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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions Where/WhereUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,10 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
drain an obsolete outbox. `OnboardingImportRecoveryModel` owns that reconciliation rather than
the process-wide `WhereModel`. Settings offers export only.
- **`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.
picker (segmented map/list) and the stepped color/emoji/icon editor for onboarding.
`PrimaryRegionSelectionModel` contains their shared state. The `RegionsSettingsView`
screen opens on selected regions and edits one appearance at a time. It uses the shared
picker for membership changes.
- **`DevicesSettingsView`** — Settings’ installation rows for local recording choice, synced
nicknames, advisory activity/permission status, and irreversible removal. Only the current row
can toggle recording. Remote rows can be renamed or removed while preserving their earlier
Expand Down
10 changes: 10 additions & 0 deletions Where/WhereUI/SnapshotTests/RegionsSettingsViewSnapshotTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SnapshotKitTesting
import Testing
@testable import WhereUI

@MainActor
struct RegionsSettingsViewSnapshotTests {
@Test func regionsSettings() async {
await assertSnapshots(of: RegionsSettingsView.self)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
155 changes: 119 additions & 36 deletions Where/WhereUI/Sources/Regions/RegionsSettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import PeriscopeCore
import RegionKit
import SFSafeSymbols
import SnapshotKit
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.
/// Settings screen for editing primary regions after onboarding. It opens on
/// the current regions, lets one region's appearance be edited at a time, and
/// keeps add/remove work behind a separate route to the shared picker.
struct RegionsSettingsView: View {
/// Regions with days in the selected year, so the picker can surface a
/// "used this year" group. Passed by `SettingsView` from the report.
Expand All @@ -15,66 +16,135 @@ struct RegionsSettingsView: View {
@Environment(WhereSession.self) private var session
@Environment(\.dismiss) private var dismiss

/// The picker/customization model, built once the current picks load.
/// The shared membership and appearance draft, built once the current picks load.
@State private var model: PrimaryRegionSelectionModel?
@State private var phase: Phase = .pick
@State private var isSaving = false
@State private var saveError = SaveErrorAlertState()

private enum Phase: Hashable {
case pick
case customize
private enum Destination: Hashable {
case appearance(Region)
case manage
}

private static let logger = WhereLog.session(RegionsSettingsViewLog.self)

var body: some View {
@Bindable var saveError = saveError

// Presented as a sheet from Settings, so it owns its navigation stack and
// explicit Cancel/Done points — making the commit boundary clear.
NavigationStack {
Group {
if let model {
content(model)
overview(model)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle(String(localized: .regionsManageTitle))
.navigationTitle(String(localized: .settingsRegionsSection))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: .commonCancel)) { dismiss() }
}
}
}
}
.navigationDestination(for: Destination.self) { destination in
if let model {
destinationView(destination, model: model)
}
}
}
.task { await loadIfNeeded() }
.interactiveDismissDisabled(isSaving)
.alert(
String(localized: .settingsRegionsSaveErrorTitle),
isPresented: $saveError.isPresented,
) {
Button(String(localized: .commonOk), role: .cancel) {}
} message: {
if let message = saveError.message {
Text(message)
}
}
// Log View Mode: reveal an inspect badge for the region-editor events. A
// no-op in release.
.debugLogInspectable(WhereLog.session(RegionsSettingsViewLog.self))
}

@ViewBuilder
private func content(_ model: PrimaryRegionSelectionModel) -> some View {
switch phase {
case .pick:
RegionPickerView(model: model)
.navigationTitle(String(localized: .regionsManageTitle))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: .commonCancel)) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: .onboardingNext)) { phase = .customize }
.disabled(!model.hasSelection)
private func overview(_ model: PrimaryRegionSelectionModel) -> some View {
List {
Section(String(localized: .regionGroupYours)) {
if model.selectedRegions.isEmpty {
Text(String(localized: .settingsRegionsEmpty))
.foregroundStyle(.secondary)
} else {
ForEach(model.selectedRegions, id: \.self) { region in
NavigationLink(value: Destination.appearance(region)) {
regionRow(region, model: model)
}
}
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) },
)
}
}

Section {
NavigationLink(value: Destination.manage) {
Label(
String(localized: .settingsRegionsManage),
systemSymbol: .map,
)
}
}
}
.disabled(isSaving)
.navigationTitle(String(localized: .settingsRegionsSection))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: .commonCancel)) { dismiss() }
.disabled(isSaving)
}
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView()
.accessibilityLabel(String(localized: .commonSave))
} else {
Button(String(localized: .commonDone)) { save(model) }
.disabled(!model.hasSelection)
}
}
}
}

private func regionRow(
_ region: Region,
model: PrimaryRegionSelectionModel,
) -> some View {
let appearance = model.appearance(for: region)
return HStack {
Text(appearance.emoji)
.accessibilityHidden(true)
Text(region.localizedName)
Spacer(minLength: 0)
Image(systemSymbol: appearance.symbolName.sfSymbol)
.foregroundStyle(appearance.color.color)
.accessibilityHidden(true)
}
.accessibilityElement(children: .combine)
}

@ViewBuilder
private func destinationView(
_ destination: Destination,
model: PrimaryRegionSelectionModel,
) -> some View {
switch destination {
case let .appearance(region):
RegionAppearanceEditor(model: model, region: region)
.navigationTitle(region.localizedName)
.navigationBarTitleDisplayMode(.inline)
case .manage:
RegionPickerView(model: model)
.navigationTitle(String(localized: .settingsRegionsManage))
.navigationBarTitleDisplayMode(.inline)
}
}

Expand All @@ -98,16 +168,19 @@ struct RegionsSettingsView: View {

private func save(_ model: PrimaryRegionSelectionModel) {
guard !isSaving else { return }
saveError.message = nil
isSaving = true
Task {
do {
try await model.commit(using: session)
dismiss()
} catch {
Self.logger(attachments: [.error(error, name: "save-error")]) {
.primaryRegionsSaveFailed(description: error.localizedDescription)
}
saveError.message = error.localizedDescription
isSaving = false
}
dismiss()
}
}
}
Expand Down Expand Up @@ -135,10 +208,20 @@ extension RegionsSettingsView: SettingsSection {
}

#if DEBUG
extension RegionsSettingsView: SnapshotProviding {
static var snapshots: [SnapshotCase] {
whereSnapshot(
name: "Overview",
configurations: .fullContentScreenDefaults,
) {
RegionsSettingsView()
.environment(PreviewSupport.loadedSession())
}
}
}

#Preview {
RegionsSettingsView()
.environment(PreviewSupport.loadedSession())
.whereBroadwayRoot()
RegionsSettingsView.snapshotPreviews
}
#endif

Expand Down
22 changes: 22 additions & 0 deletions Where/WhereUI/Sources/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -8753,6 +8753,17 @@
}
}
},
"settings.regions.manage" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Manage Regions"
}
}
}
},
"settings.regions.row" : {
"extractionState" : "manual",
"localizations" : {
Expand All @@ -8764,6 +8775,17 @@
}
}
},
"settings.regions.saveError.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Unable to Save Regions"
}
}
}
},
"settings.regions.section" : {
"comment" : "Label for the settings option to manage your regions.",
"extractionState" : "manual",
Expand Down
21 changes: 21 additions & 0 deletions Where/WhereUI/Tests/PrimaryRegionSelectionModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ struct PrimaryRegionSelectionModelTests {
#expect(try await session.services.trackedRegions() == [.california])
}

@Test func editingOneAppearancePreservesOtherRegionsAndOrder() async throws {
let session = PreviewSupport.loadedSession()
let california = RegionAppearance(color: .orange, emoji: "🌴", symbolName: .sunMaxFill)
let newYork = RegionAppearance(color: .indigo, emoji: "🗽", symbolName: .building2Fill)
try await session.services.setPrimaryRegions([
PrimaryRegion(region: .california, appearance: california, order: 0),
PrimaryRegion(region: .newYork, appearance: newYork, order: 1),
])

let existing = try await session.services.primaryRegions()
let model = PrimaryRegionSelectionModel(existing: existing)
model.setEmoji("🌉", for: .california)
try await model.commit(using: session)

let saved = try await session.services.primaryRegions()
#expect(saved.map(\.region) == [.california, .newYork])
#expect(saved[0].appearance?.emoji == "🌉")
#expect(saved[0].appearance?.color == california.color)
#expect(saved[1].appearance == newYork)
}

@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
Expand Down
Loading