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
2 changes: 2 additions & 0 deletions Where/WhereUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ worked examples.
count animation and outline-cache behavior.
- Keep the DEBUG Ranking Animation Lab session-only. It tunes the containing
Location-card stack, never Card Designer persistence, exports, or app overrides.
- Keep the Appearance welcome reset under `#if DEBUG`. Clear only the saved welcome region through `YearReportModel.resetLocationWelcome()`.
- Keep welcome-card arrival, departure, and scrim timing in `locationWelcome.motion`. Apply spatial transitions only to the card layer.

## Testing

Expand Down
8 changes: 6 additions & 2 deletions Where/WhereUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,12 @@ the feature [`Where/AGENTS.md`](../AGENTS.md) and this module's
### Reusable views & styling

- **`RegionWelcomeCard`** — a centered Locations overlay that combines a region's emoji,
icon, outline, Liquid Glass card treatment, and passport ink. It uses a spring transition
and an opacity-only transition when Reduce Motion is enabled.
icon, outline, Liquid Glass card treatment, and passport ink. The card stamps into place
with a quick tilted approach and spring settle, then lifts away on dismissal.
The scrim fades independently. Reduce Motion uses a short fade for both layers.
Debug builds include **Reset Welcome Card** in Settings > Appearance beside the welcome-card toggle.
The reset clears the saved region so the next Locations visit can show the card again.
Welcome cards must be enabled, and the device must resolve a tracked region with recording active.

- **`OnboardingView` / `OnboardingFlowModel`** — the rendered first-run flow and its view-scoped
observable coordinator, registered for the launch's
Expand Down
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.
7 changes: 7 additions & 0 deletions Where/WhereUI/Sources/Model/YearReportModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ public final class YearReportModel {
}
}

#if DEBUG
/// Clears the acknowledgement so the next visible Locations lookup can welcome again.
func resetLocationWelcome() {
preferences.lastWelcomedRegion = nil
}
#endif

/// GPS border-drift detection threshold (device setting). The setter persists
/// it, forces a badge recount, and — through the observed mirror — re-keys
/// `dataIssueScanInputs` so the Resolve list re-scans immediately, not just on
Expand Down
63 changes: 36 additions & 27 deletions Where/WhereUI/Sources/Primary/LocationWelcomeOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,54 @@ import UIKit

/// The modal scrim and adaptive placement for a Locations welcome card.
struct LocationWelcomeOverlay: View {
let presentation: LocationWelcomeModel.Presentation
let presentation: LocationWelcomeModel.Presentation?
let dismissAction: () -> Void
let planStayAction: ((Region) -> Void)?

@AccessibilityFocusState private var isCardFocused: Bool
@Environment(\.stylesheet) private var stylesheet

var body: some View {
// Keep the container mounted so the scrim never inherits the card's motion.
ZStack {
Color.black
.opacity(stylesheet.locationWelcome.scrimOpacity)
.ignoresSafeArea()
.accessibilityHidden(true)
if let presentation {
Color.black
.opacity(stylesheet.locationWelcome.scrimOpacity)
.ignoresSafeArea()
.accessibilityHidden(true)
.transition(.opacity)
.zIndex(0)

GeometryReader { proxy in
ScrollView {
RegionWelcomeCard(
presentation: presentation,
dismissAction: dismissAction,
planStayAction: planStayAction,
)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, stylesheet.spacing.xxxLarge)
.padding(.vertical, stylesheet.spacing.xxxLarge)
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
.accessibilityFocused($isCardFocused)
GeometryReader { proxy in
ScrollView {
RegionWelcomeCard(
presentation: presentation,
dismissAction: dismissAction,
planStayAction: planStayAction,
)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, stylesheet.spacing.xxxLarge)
.padding(.vertical, stylesheet.spacing.xxxLarge)
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
.accessibilityFocused($isCardFocused)
}
.scrollBounceBehavior(.basedOnSize)
}
.contentShape(Rectangle())
.accessibilityAddTraits(.isModal)
.transition(stylesheet.locationWelcome.motion.transition)
.zIndex(1)
.onAppear {
isCardFocused = true
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
.onDisappear {
isCardFocused = false
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
.scrollBounceBehavior(.basedOnSize)
}
}
.contentShape(Rectangle())
.accessibilityAddTraits(.isModal)
.onAppear {
isCardFocused = true
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
.onDisappear {
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
.animation(stylesheet.locationWelcome.motion.scrimAnimation, value: presentation)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import SwiftUI

/// Moves the complete welcome card like a stamp approaching or lifting from paper.
struct LocationWelcomeTransitionModifier: ViewModifier {
let scale: CGFloat
let rotationDegrees: Double
let verticalOffset: CGFloat

func body(content: Content) -> some View {
content
.scaleEffect(scale)
.rotationEffect(.degrees(rotationDegrees))
.offset(y: verticalOffset)
}
}
16 changes: 6 additions & 10 deletions Where/WhereUI/Sources/Primary/LocationsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,12 @@ struct LocationsView: View {
}
.accessibilityHidden(welcomePresentation != nil)
.overlay {
if let presentation = welcomePresentation {
LocationWelcomeOverlay(
presentation: presentation,
dismissAction: welcome.dismiss,
planStayAction: welcomePlanStayAction,
)
.transition(stylesheet.locationWelcome.motion.transition)
}
LocationWelcomeOverlay(
presentation: welcomePresentation,
dismissAction: welcome.dismiss,
planStayAction: welcomePlanStayAction,
)
}
.animation(stylesheet.locationWelcome.motion.animation, value: welcomePresentation)
.task(id: isWelcomeLookupActive) {
guard isWelcomeLookupActive else { return }
await welcome.resolve()
Expand Down Expand Up @@ -306,7 +302,7 @@ struct LocationsView: View {
}

private func planStayFromWelcome(_ region: Region) {
withAnimation(stylesheet.locationWelcome.motion.animation) {
withAnimation(stylesheet.locationWelcome.motion.departure.animation) {
welcome.dismiss()
} completion: {
editPlannedStay(region)
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 @@ -6161,6 +6161,28 @@
}
}
},
"settings.appearance.locationWelcome.reset.footer" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Developer: Clear the saved welcome region, then return to Locations to show the card again."
}
}
}
},
"settings.appearance.locationWelcome.reset.title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Reset Welcome Card"
}
}
}
},
"settings.appearance.locationWelcome.toggle" : {
"comment" : "Toggle that controls whether live-region welcome cards appear on the Locations tab.",
"extractionState" : "manual",
Expand Down
23 changes: 22 additions & 1 deletion Where/WhereUI/Sources/Settings/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,24 @@ struct AppearanceSettingsView: View {
)
}
.settingsRow(Item.locationWelcome)

#if DEBUG
Button(action: report.resetLocationWelcome) {
Label(
String(localized: .settingsAppearanceLocationWelcomeResetTitle),
systemSymbol: .arrowCounterclockwise,
)
}
.disabled(!report.showsLocationWelcome)
.settingsRow(Item.resetLocationWelcome)
#endif
} footer: {
Text(String(localized: .settingsAppearanceLocationWelcomeFooter))
VStack(alignment: .leading) {
Text(String(localized: .settingsAppearanceLocationWelcomeFooter))
#if DEBUG
Text(String(localized: .settingsAppearanceLocationWelcomeResetFooter))
#endif
}
}

Section {
Expand Down Expand Up @@ -157,6 +173,7 @@ extension AppearanceSettingsView: SettingsSection {
case locationForecasts
case appIcon
#if DEBUG
case resetLocationWelcome
case cardDesigner
case rankingAnimation
#endif
Expand All @@ -172,6 +189,8 @@ extension AppearanceSettingsView: SettingsSection {
String(localized: .settingsAppearanceLocationForecastsToggle)
case .appIcon: String(localized: .settingsAppIconLink)
#if DEBUG
case .resetLocationWelcome:
String(localized: .settingsAppearanceLocationWelcomeResetTitle)
case .cardDesigner: String(localized: .cardDesignerTitle)
case .rankingAnimation: String(localized: .rankingAnimationTitle)
#endif
Expand All @@ -190,6 +209,8 @@ extension AppearanceSettingsView: SettingsSection {
splitKeywords(String(localized: .settingsKeywordsLocationForecasts))
case .appIcon: splitKeywords(String(localized: .settingsKeywordsAppIcon))
#if DEBUG
case .resetLocationWelcome:
splitKeywords(String(localized: .settingsKeywordsLocationWelcome))
case .cardDesigner:
splitKeywords(String(localized: .cardDesignerSettingsKeywords))
case .rankingAnimation:
Expand Down
73 changes: 57 additions & 16 deletions Where/WhereUI/Sources/Shared/WhereStylesheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,33 +131,74 @@ extension WhereStylesheet {
}

struct Motion: Equatable {
var animation: Animation
var scale: CGFloat
var verticalOffset: CGFloat
var arrival: Movement
var departure: Movement
var scrimAnimation: Animation
var usesSpatialMotion: Bool

var transition: AnyTransition {
let base: AnyTransition = usesSpatialMotion
? .scale(scale: scale).combined(with: .offset(y: verticalOffset))
struct Movement: Equatable {
var animation: Animation
var scale: CGFloat
var rotationDegrees: Double
var verticalOffset: CGFloat

var transition: AnyTransition {
.modifier(
active: LocationWelcomeTransitionModifier(
scale: scale,
rotationDegrees: rotationDegrees,
verticalOffset: verticalOffset,
),
identity: LocationWelcomeTransitionModifier(
scale: 1,
rotationDegrees: 0,
verticalOffset: 0,
),
)
.combined(with: .opacity)
: .opacity
return .asymmetric(
insertion: base.animation(animation),
removal: base.animation(animation),
}
}

var transition: AnyTransition {
.asymmetric(
insertion: (usesSpatialMotion ? arrival.transition : .opacity)
.animation(arrival.animation),
removal: (usesSpatialMotion ? departure.transition : .opacity)
.animation(departure.animation),
)
}

static let standard = Motion(
animation: .spring(duration: 0.62, bounce: 0.32),
scale: 0.78,
verticalOffset: 34,
arrival: Movement(
animation: .spring(duration: 0.3, bounce: 0.28),
scale: 1.28,
rotationDegrees: -9,
verticalOffset: -24,
),
departure: Movement(
animation: .easeOut(duration: 0.16),
scale: 1.045,
rotationDegrees: 3,
verticalOffset: -10,
),
scrimAnimation: .easeOut(duration: 0.16),
usesSpatialMotion: true,
)

static let reduced = Motion(
animation: .easeInOut(duration: 0.18),
scale: 1,
verticalOffset: 0,
arrival: Movement(
animation: .easeInOut(duration: 0.16),
scale: 1,
rotationDegrees: 0,
verticalOffset: 0,
),
departure: Movement(
animation: .easeInOut(duration: 0.16),
scale: 1,
rotationDegrees: 0,
verticalOffset: 0,
),
scrimAnimation: .easeInOut(duration: 0.16),
usesSpatialMotion: false,
)
}
Expand Down
Loading
Loading