Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-27 14:44:02 -06:00
parent 963371d619
commit 69e548b13c
6 changed files with 411 additions and 25 deletions

View File

@ -65,6 +65,7 @@ struct AndromidaApp: App {
SetupWizardView(
store: store,
categoryStore: categoryStore,
reminderScheduler: store.reminderScheduler,
onComplete: {
justCompletedWizard = true
withAnimation {

View File

@ -958,6 +958,9 @@
"comment" : "The title of the navigation bar for editing a ritual.",
"isCommentAutoGenerated" : true
},
"Enable Notifications" : {
"comment" : "Primary button text on notification permission screen during onboarding."
},
"End" : {
},
@ -1140,6 +1143,12 @@
"comment" : "The text for the \"Get Started\" button in the welcome screen.",
"isCommentAutoGenerated" : true
},
"Get a gentle nudge each morning to start your day right" : {
"comment" : "Description for notification permission screen when user selected morning rituals."
},
"Get a gentle reminder when it's time for your rituals" : {
"comment" : "Default description for notification permission screen."
},
"Give your mind a break from screens." : {
"comment" : "Notes for a ritual preset focused on giving the mind a break from screens.",
"isCommentAutoGenerated" : true
@ -1405,6 +1414,9 @@
"comment" : "Title of a navigation row in the Settings view that takes the user to a view managing ritual categories.",
"isCommentAutoGenerated" : true
},
"Maybe Later" : {
"comment" : "Secondary button text on notification permission screen during onboarding to skip enabling notifications."
},
"Midday" : {
"comment" : "Description of a ritual is typically performed during the day.",
"isCommentAutoGenerated" : true
@ -2162,6 +2174,54 @@
"comment" : "A button label that allows users to skip creating a new ritual for now.",
"isCommentAutoGenerated" : true
},
"Skip setup?" : {
"comment" : "Alert title asking if the user wants to skip onboarding setup.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Skip setup?"
}
}
}
},
"Skip" : {
"comment" : "Button label to skip onboarding.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Skip"
}
}
}
},
"Keep going" : {
"comment" : "Cancel button label to continue onboarding.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Keep going"
}
}
}
},
"You can complete setup later in Settings." : {
"comment" : "Alert message explaining that setup can be completed later in Settings.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "You can complete setup later in Settings."
}
}
}
},
"Sleep Preparation" : {
"comment" : "Theme of the \"Sleep Preparation\" ritual preset.",
"isCommentAutoGenerated" : true
@ -2222,6 +2282,9 @@
"comment" : "Description of a trend direction when there is no significant change.",
"isCommentAutoGenerated" : true
},
"Stay on track" : {
"comment" : "Headline on the notification permission screen during onboarding."
},
"Start" : {
"comment" : "A button that starts a new arc for a ritual.",
"isCommentAutoGenerated" : true
@ -2560,6 +2623,9 @@
"comment" : "The title of the welcome screen in the setup wizard.",
"isCommentAutoGenerated" : true
},
"We'll remind you at the perfect times for your morning and evening rituals" : {
"comment" : "Description for notification permission screen when user selected both morning and evening rituals."
},
"Wellness" : {
"comment" : "The category of the morning ritual.",
"isCommentAutoGenerated" : true
@ -2614,6 +2680,9 @@
"comment" : "Notes section of a ritual preset focused on sleep preparation.",
"isCommentAutoGenerated" : true
},
"Wind down with a reminder when it's time for your evening ritual" : {
"comment" : "Description for notification permission screen when user selected evening rituals."
},
"Wind down with intention" : {
"comment" : "Subtitle for the \"Evening\" section of the \"Both\" time preference option in the onboarding flow.",
"isCommentAutoGenerated" : true

View File

@ -1,6 +1,7 @@
import Foundation
import Observation
import SwiftData
import CoreData
import Bedrock
@MainActor
@ -12,6 +13,7 @@ final class RitualStore: RitualStoreProviding {
@ObservationIgnored private let calendar: Calendar
@ObservationIgnored private let dayFormatter: DateFormatter
@ObservationIgnored private let displayFormatter: DateFormatter
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
private(set) var rituals: [Ritual] = []
private(set) var currentRituals: [Ritual] = []
@ -48,6 +50,26 @@ final class RitualStore: RitualStoreProviding {
displayFormatter.dateStyle = .full
displayFormatter.timeStyle = .none
loadRitualsIfNeeded()
observeRemoteChanges()
}
deinit {
if let observer = remoteChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
/// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs.
private func observeRemoteChanges() {
remoteChangeObserver = NotificationCenter.default.addObserver(
forName: .NSPersistentStoreRemoteChange,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.reloadRituals()
}
}
}
var activeRitual: Ritual? {

View File

@ -6,6 +6,8 @@ import Bedrock
@Observable
final class SettingsStore: CloudSyncable, ThemeProviding {
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
@ObservationIgnored private var cloudChangeObserver: NSObjectProtocol?
@ObservationIgnored private var isApplyingCloudUpdate = false
/// Observable copy of last sync date, updated when sync completes.
private(set) var lastSyncDate: Date?
@ -15,41 +17,57 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
/// Observable copy of initial sync state.
private(set) var hasCompletedInitialSync: Bool = false
var hapticsEnabled: Bool {
get { cloudSync.data.hapticsEnabled }
set { update { $0.hapticsEnabled = newValue } }
var hapticsEnabled: Bool = AppSettingsData.empty.hapticsEnabled {
didSet {
updateSetting(\.hapticsEnabled, value: hapticsEnabled, oldValue: oldValue)
}
}
var soundEnabled: Bool {
get { cloudSync.data.soundEnabled }
set { update { $0.soundEnabled = newValue } }
var soundEnabled: Bool = AppSettingsData.empty.soundEnabled {
didSet {
updateSetting(\.soundEnabled, value: soundEnabled, oldValue: oldValue)
}
}
var theme: AppTheme {
get { cloudSync.data.theme }
set { update { $0.theme = newValue } }
var theme: AppTheme = AppSettingsData.empty.theme {
didSet {
updateSetting(\.theme, value: theme, oldValue: oldValue)
}
}
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
var iCloudEnabled: Bool {
get { cloudSync.iCloudEnabled }
set { cloudSync.iCloudEnabled = newValue }
set {
cloudSync.iCloudEnabled = newValue
refreshSyncState()
}
}
init() {
// Initialize observable properties from cloudSync
refreshSettingsData()
refreshSyncState()
observeCloudChanges()
}
deinit {
if let cloudChangeObserver {
NotificationCenter.default.removeObserver(cloudChangeObserver)
}
}
func forceSync() {
cloudSync.sync()
refreshSettingsData()
refreshSyncState()
}
func refresh() {
cloudSync.sync()
refreshSettingsData()
refreshSyncState()
}
@ -57,6 +75,7 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
cloudSync.update { data in
transform(&data)
}
refreshSettingsData()
refreshSyncState()
}
@ -66,6 +85,49 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
syncStatus = cloudSync.syncStatus
hasCompletedInitialSync = cloudSync.hasCompletedInitialSync
}
private func refreshSettingsData() {
isApplyingCloudUpdate = true
hapticsEnabled = cloudSync.data.hapticsEnabled
soundEnabled = cloudSync.data.soundEnabled
theme = cloudSync.data.theme
isApplyingCloudUpdate = false
}
private func observeCloudChanges() {
cloudSync.onCloudDataReceived = { [weak self] _ in
self?.handleCloudDataChange()
}
cloudChangeObserver = NotificationCenter.default.addObserver(
forName: .persistedDataDidChange,
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
let identifier = notification.userInfo?["dataIdentifier"] as? String,
identifier == AppSettingsData.dataIdentifier else {
return
}
self.handleCloudDataChange()
}
}
private func handleCloudDataChange() {
refreshSettingsData()
refreshSyncState()
}
private func updateSetting<T: Equatable>(
_ keyPath: WritableKeyPath<AppSettingsData, T>,
value: T,
oldValue: T
) {
guard !isApplyingCloudUpdate, value != oldValue else { return }
update { data in
data[keyPath: keyPath] = value
}
}
}
extension SettingsStore {

View File

@ -0,0 +1,194 @@
import SwiftUI
import Bedrock
/// The notification permission screen where users can enable reminders.
/// Shown after the first check-in to maximize conversion after experiencing value.
struct NotificationStepView: View {
let selectedTime: OnboardingTimePreference?
let reminderScheduler: ReminderScheduler
let onComplete: () -> Void
@State private var animateIcon = false
@State private var animateContent = false
@State private var animateButtons = false
@State private var isRequestingPermission = false
/// Personalized description based on the user's selected time preference
private var personalizedDescription: String {
switch selectedTime {
case .morning:
return String(localized: "Get a gentle nudge each morning to start your day right")
case .evening:
return String(localized: "Wind down with a reminder when it's time for your evening ritual")
case .both:
return String(localized: "We'll remind you at the perfect times for your morning and evening rituals")
case nil:
return String(localized: "Get a gentle reminder when it's time for your rituals")
}
}
var body: some View {
VStack(spacing: Design.Spacing.xxLarge) {
Spacer()
// Animated bell icon
animatedBellView
.frame(width: 160, height: 160)
.opacity(animateIcon ? 1 : 0)
.scaleEffect(animateIcon ? 1 : 0.8)
// Text content
VStack(spacing: Design.Spacing.medium) {
Text(String(localized: "Stay on track"))
.typography(.heroBold)
.foregroundStyle(AppTextColors.primary)
.multilineTextAlignment(.center)
Text(personalizedDescription)
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xxLarge)
}
.opacity(animateContent ? 1 : 0)
.offset(y: animateContent ? 0 : 20)
Spacer()
// Buttons
VStack(spacing: Design.Spacing.medium) {
// Primary CTA
Button(action: handleEnableNotifications) {
HStack(spacing: Design.Spacing.small) {
if isRequestingPermission {
ProgressView()
.tint(AppTextColors.inverse)
} else {
Image(systemName: "bell.fill")
Text(String(localized: "Enable Notifications"))
}
}
.typography(.heading)
.foregroundStyle(AppTextColors.inverse)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.Size.buttonHeight)
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.disabled(isRequestingPermission)
// Secondary skip option
Button(action: onComplete) {
Text(String(localized: "Maybe Later"))
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
}
.disabled(isRequestingPermission)
}
.padding(.horizontal, Design.Spacing.xxLarge)
.opacity(animateButtons ? 1 : 0)
.offset(y: animateButtons ? 0 : 20)
Spacer()
.frame(height: Design.Spacing.xxLarge)
}
.onAppear {
startAnimations()
}
}
// MARK: - Animated Bell
private var animatedBellView: some View {
ZStack {
// Outer pulse circle
Circle()
.fill(AppAccent.primary.opacity(0.1))
.frame(width: 160, height: 160)
.scaleEffect(animateIcon ? 1.1 : 1.0)
.animation(
.easeInOut(duration: 2).repeatForever(autoreverses: true),
value: animateIcon
)
// Middle circle
Circle()
.fill(AppAccent.primary.opacity(0.15))
.frame(width: 120, height: 120)
.scaleEffect(animateIcon ? 1.05 : 1.0)
.animation(
.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.2),
value: animateIcon
)
// Inner circle with bell
Circle()
.fill(AppAccent.primary.opacity(0.2))
.frame(width: 80, height: 80)
// Bell icon with ring animation
Image(systemName: "bell.fill")
.font(.system(size: 36, weight: .medium))
.foregroundStyle(AppAccent.primary)
.rotationEffect(.degrees(animateIcon ? 10 : -10))
.animation(
.easeInOut(duration: 0.5).repeatForever(autoreverses: true),
value: animateIcon
)
}
}
// MARK: - Actions
private func handleEnableNotifications() {
isRequestingPermission = true
Task {
let granted = await reminderScheduler.requestAuthorization()
if granted {
// Enable reminders in the scheduler
reminderScheduler.remindersEnabled = true
}
await MainActor.run {
isRequestingPermission = false
onComplete()
}
}
}
// MARK: - Animations
private func startAnimations() {
// Stagger the animations
withAnimation(.easeOut(duration: 0.6)) {
animateIcon = true
}
withAnimation(.easeOut(duration: 0.6).delay(0.3)) {
animateContent = true
}
withAnimation(.easeOut(duration: 0.6).delay(0.5)) {
animateButtons = true
}
}
}
#Preview {
ZStack {
LinearGradient(
colors: [AppSurface.primary, AppSurface.secondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
NotificationStepView(
selectedTime: .morning,
reminderScheduler: ReminderScheduler(),
onComplete: {}
)
}
}

View File

@ -6,6 +6,7 @@ import Bedrock
struct SetupWizardView: View {
@Bindable var store: RitualStore
@Bindable var categoryStore: CategoryStore
let reminderScheduler: ReminderScheduler
let onComplete: () -> Void
@State private var currentStep: WizardStep = .welcome
@ -20,13 +21,16 @@ struct SetupWizardView: View {
@State private var pendingPresets: [RitualPreset] = []
@State private var currentPresetIndex: Int = 0
@State private var isShowingSkipConfirmation = false
enum WizardStep: Int, CaseIterable {
case welcome = 0
case goalSelection = 1
case timeSelection = 2
case ritualPreview = 3
case firstCheckIn = 4
case whatsNext = 5
case notifications = 5
case whatsNext = 6
}
/// Whether the user selected "Both" for time preference
@ -56,7 +60,7 @@ struct SetupWizardView: View {
/// Whether to show the back button
private var canGoBack: Bool {
switch currentStep {
case .welcome, .firstCheckIn, .whatsNext:
case .welcome, .firstCheckIn, .notifications, .whatsNext:
return false
case .goalSelection:
return true
@ -78,8 +82,9 @@ struct SetupWizardView: View {
.ignoresSafeArea()
VStack(spacing: 0) {
// Header with back button and progress (hidden on welcome and whatsNext)
if currentStep != .welcome && currentStep != .whatsNext {
// Header with back button and progress (hidden on welcome, notifications, and whatsNext)
// Notifications step has its own "Maybe Later" option so skip button is redundant
if currentStep != .welcome && currentStep != .notifications && currentStep != .whatsNext {
headerView
.padding(.top, Design.Spacing.medium)
.padding(.horizontal, Design.Spacing.large)
@ -120,10 +125,17 @@ struct SetupWizardView: View {
store: store,
ritual: ritual,
hasCompletedCheckIn: $hasCompletedFirstCheckIn,
onComplete: { advanceToWhatsNext() }
onComplete: { advanceToNotifications() }
)
}
case .notifications:
NotificationStepView(
selectedTime: selectedTime,
reminderScheduler: reminderScheduler,
onComplete: { advanceToWhatsNext() }
)
case .whatsNext:
WhatsNextStepView(onComplete: onComplete)
}
@ -136,6 +148,14 @@ struct SetupWizardView: View {
}
}
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
.alert(String(localized: "Skip setup?"), isPresented: $isShowingSkipConfirmation) {
Button(String(localized: "Keep going"), role: .cancel) {}
Button(String(localized: "Skip"), role: .destructive) {
skipOnboarding()
}
} message: {
Text(String(localized: "You can complete setup later in Settings."))
}
}
// MARK: - Header View
@ -156,6 +176,11 @@ struct SetupWizardView: View {
}
}
Spacer()
Button(String(localized: "Skip")) {
isShowingSkipConfirmation = true
}
.font(.body)
.foregroundStyle(AppTextColors.secondary)
}
// Progress indicator
@ -190,13 +215,15 @@ struct SetupWizardView: View {
case .welcome:
return 0.0
case .goalSelection:
return 0.25
return 0.2
case .timeSelection:
return 0.5
return 0.4
case .ritualPreview:
return 0.7
return 0.55
case .firstCheckIn:
return 0.9
return 0.7
case .notifications:
return 0.85
case .whatsNext:
return 1.0
}
@ -227,12 +254,22 @@ struct SetupWizardView: View {
}
}
private func advanceToNotifications() {
withAnimation {
currentStep = .notifications
}
}
private func advanceToWhatsNext() {
withAnimation {
currentStep = .whatsNext
}
}
private func skipOnboarding() {
onComplete()
}
// MARK: - Time Selection Handler
private func handleTimeSelectionContinue() {
@ -266,7 +303,7 @@ struct SetupWizardView: View {
hasCompletedFirstCheckIn = false
if presets.isEmpty {
currentStep = .whatsNext
currentStep = .notifications
} else {
currentStep = .ritualPreview
}
@ -294,8 +331,8 @@ struct SetupWizardView: View {
// Go to first check-in
currentStep = .firstCheckIn
} else {
// No rituals created, go to what's next
currentStep = .whatsNext
// No rituals created, skip to notifications
currentStep = .notifications
}
}
}
@ -305,6 +342,7 @@ struct SetupWizardView: View {
SetupWizardView(
store: RitualStore.preview,
categoryStore: CategoryStore.preview,
reminderScheduler: ReminderScheduler(),
onComplete: {}
)
}