From 69e548b13cf060b0917f68ec4e33eff52ae00905 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Tue, 27 Jan 2026 14:44:02 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida/AndromidaApp.swift | 1 + .../App/Localization/Localizable.xcstrings | 69 +++++++ Andromida/App/State/RitualStore.swift | 22 ++ Andromida/App/State/SettingsStore.swift | 88 ++++++-- .../Onboarding/NotificationStepView.swift | 194 ++++++++++++++++++ .../Views/Onboarding/SetupWizardView.swift | 62 ++++-- 6 files changed, 411 insertions(+), 25 deletions(-) create mode 100644 Andromida/App/Views/Onboarding/NotificationStepView.swift diff --git a/Andromida/AndromidaApp.swift b/Andromida/AndromidaApp.swift index c2f1925..b97adf5 100644 --- a/Andromida/AndromidaApp.swift +++ b/Andromida/AndromidaApp.swift @@ -65,6 +65,7 @@ struct AndromidaApp: App { SetupWizardView( store: store, categoryStore: categoryStore, + reminderScheduler: store.reminderScheduler, onComplete: { justCompletedWizard = true withAnimation { diff --git a/Andromida/App/Localization/Localizable.xcstrings b/Andromida/App/Localization/Localizable.xcstrings index f821d8c..916c3bb 100644 --- a/Andromida/App/Localization/Localizable.xcstrings +++ b/Andromida/App/Localization/Localizable.xcstrings @@ -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 diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index 6d83887..6a7dae3 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -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? { diff --git a/Andromida/App/State/SettingsStore.swift b/Andromida/App/State/SettingsStore.swift index 87c3790..848f1ac 100644 --- a/Andromida/App/State/SettingsStore.swift +++ b/Andromida/App/State/SettingsStore.swift @@ -6,6 +6,8 @@ import Bedrock @Observable final class SettingsStore: CloudSyncable, ThemeProviding { @ObservationIgnored private let cloudSync = CloudSyncManager() + @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( + _ keyPath: WritableKeyPath, + value: T, + oldValue: T + ) { + guard !isApplyingCloudUpdate, value != oldValue else { return } + update { data in + data[keyPath: keyPath] = value + } + } } extension SettingsStore { diff --git a/Andromida/App/Views/Onboarding/NotificationStepView.swift b/Andromida/App/Views/Onboarding/NotificationStepView.swift new file mode 100644 index 0000000..1fb1f95 --- /dev/null +++ b/Andromida/App/Views/Onboarding/NotificationStepView.swift @@ -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: {} + ) + } +} diff --git a/Andromida/App/Views/Onboarding/SetupWizardView.swift b/Andromida/App/Views/Onboarding/SetupWizardView.swift index fcf562e..f064bdf 100644 --- a/Andromida/App/Views/Onboarding/SetupWizardView.swift +++ b/Andromida/App/Views/Onboarding/SetupWizardView.swift @@ -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: {} ) }