Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
963371d619
commit
69e548b13c
@ -65,6 +65,7 @@ struct AndromidaApp: App {
|
|||||||
SetupWizardView(
|
SetupWizardView(
|
||||||
store: store,
|
store: store,
|
||||||
categoryStore: categoryStore,
|
categoryStore: categoryStore,
|
||||||
|
reminderScheduler: store.reminderScheduler,
|
||||||
onComplete: {
|
onComplete: {
|
||||||
justCompletedWizard = true
|
justCompletedWizard = true
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
|||||||
@ -958,6 +958,9 @@
|
|||||||
"comment" : "The title of the navigation bar for editing a ritual.",
|
"comment" : "The title of the navigation bar for editing a ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Enable Notifications" : {
|
||||||
|
"comment" : "Primary button text on notification permission screen during onboarding."
|
||||||
|
},
|
||||||
"End" : {
|
"End" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -1140,6 +1143,12 @@
|
|||||||
"comment" : "The text for the \"Get Started\" button in the welcome screen.",
|
"comment" : "The text for the \"Get Started\" button in the welcome screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"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." : {
|
"Give your mind a break from screens." : {
|
||||||
"comment" : "Notes for a ritual preset focused on giving the mind a break from screens.",
|
"comment" : "Notes for a ritual preset focused on giving the mind a break from screens.",
|
||||||
"isCommentAutoGenerated" : true
|
"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.",
|
"comment" : "Title of a navigation row in the Settings view that takes the user to a view managing ritual categories.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Maybe Later" : {
|
||||||
|
"comment" : "Secondary button text on notification permission screen during onboarding to skip enabling notifications."
|
||||||
|
},
|
||||||
"Midday" : {
|
"Midday" : {
|
||||||
"comment" : "Description of a ritual is typically performed during the day.",
|
"comment" : "Description of a ritual is typically performed during the day.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2162,6 +2174,54 @@
|
|||||||
"comment" : "A button label that allows users to skip creating a new ritual for now.",
|
"comment" : "A button label that allows users to skip creating a new ritual for now.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Sleep Preparation" : {
|
||||||
"comment" : "Theme of the \"Sleep Preparation\" ritual preset.",
|
"comment" : "Theme of the \"Sleep Preparation\" ritual preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2222,6 +2282,9 @@
|
|||||||
"comment" : "Description of a trend direction when there is no significant change.",
|
"comment" : "Description of a trend direction when there is no significant change.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Stay on track" : {
|
||||||
|
"comment" : "Headline on the notification permission screen during onboarding."
|
||||||
|
},
|
||||||
"Start" : {
|
"Start" : {
|
||||||
"comment" : "A button that starts a new arc for a ritual.",
|
"comment" : "A button that starts a new arc for a ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2560,6 +2623,9 @@
|
|||||||
"comment" : "The title of the welcome screen in the setup wizard.",
|
"comment" : "The title of the welcome screen in the setup wizard.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Wellness" : {
|
||||||
"comment" : "The category of the morning ritual.",
|
"comment" : "The category of the morning ritual.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -2614,6 +2680,9 @@
|
|||||||
"comment" : "Notes section of a ritual preset focused on sleep preparation.",
|
"comment" : "Notes section of a ritual preset focused on sleep preparation.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Wind down with intention" : {
|
||||||
"comment" : "Subtitle for the \"Evening\" section of the \"Both\" time preference option in the onboarding flow.",
|
"comment" : "Subtitle for the \"Evening\" section of the \"Both\" time preference option in the onboarding flow.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import CoreData
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -12,6 +13,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
@ObservationIgnored private let calendar: Calendar
|
@ObservationIgnored private let calendar: Calendar
|
||||||
@ObservationIgnored private let dayFormatter: DateFormatter
|
@ObservationIgnored private let dayFormatter: DateFormatter
|
||||||
@ObservationIgnored private let displayFormatter: DateFormatter
|
@ObservationIgnored private let displayFormatter: DateFormatter
|
||||||
|
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
|
||||||
|
|
||||||
private(set) var rituals: [Ritual] = []
|
private(set) var rituals: [Ritual] = []
|
||||||
private(set) var currentRituals: [Ritual] = []
|
private(set) var currentRituals: [Ritual] = []
|
||||||
@ -48,6 +50,26 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
displayFormatter.dateStyle = .full
|
displayFormatter.dateStyle = .full
|
||||||
displayFormatter.timeStyle = .none
|
displayFormatter.timeStyle = .none
|
||||||
loadRitualsIfNeeded()
|
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? {
|
var activeRitual: Ritual? {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import Bedrock
|
|||||||
@Observable
|
@Observable
|
||||||
final class SettingsStore: CloudSyncable, ThemeProviding {
|
final class SettingsStore: CloudSyncable, ThemeProviding {
|
||||||
@ObservationIgnored private let cloudSync = CloudSyncManager<AppSettingsData>()
|
@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.
|
/// Observable copy of last sync date, updated when sync completes.
|
||||||
private(set) var lastSyncDate: Date?
|
private(set) var lastSyncDate: Date?
|
||||||
@ -16,40 +18,56 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
|
|||||||
/// Observable copy of initial sync state.
|
/// Observable copy of initial sync state.
|
||||||
private(set) var hasCompletedInitialSync: Bool = false
|
private(set) var hasCompletedInitialSync: Bool = false
|
||||||
|
|
||||||
var hapticsEnabled: Bool {
|
var hapticsEnabled: Bool = AppSettingsData.empty.hapticsEnabled {
|
||||||
get { cloudSync.data.hapticsEnabled }
|
didSet {
|
||||||
set { update { $0.hapticsEnabled = newValue } }
|
updateSetting(\.hapticsEnabled, value: hapticsEnabled, oldValue: oldValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var soundEnabled: Bool {
|
var soundEnabled: Bool = AppSettingsData.empty.soundEnabled {
|
||||||
get { cloudSync.data.soundEnabled }
|
didSet {
|
||||||
set { update { $0.soundEnabled = newValue } }
|
updateSetting(\.soundEnabled, value: soundEnabled, oldValue: oldValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var theme: AppTheme {
|
var theme: AppTheme = AppSettingsData.empty.theme {
|
||||||
get { cloudSync.data.theme }
|
didSet {
|
||||||
set { update { $0.theme = newValue } }
|
updateSetting(\.theme, value: theme, oldValue: oldValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
|
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
|
||||||
|
|
||||||
var iCloudEnabled: Bool {
|
var iCloudEnabled: Bool {
|
||||||
get { cloudSync.iCloudEnabled }
|
get { cloudSync.iCloudEnabled }
|
||||||
set { cloudSync.iCloudEnabled = newValue }
|
set {
|
||||||
|
cloudSync.iCloudEnabled = newValue
|
||||||
|
refreshSyncState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize observable properties from cloudSync
|
// Initialize observable properties from cloudSync
|
||||||
|
refreshSettingsData()
|
||||||
refreshSyncState()
|
refreshSyncState()
|
||||||
|
observeCloudChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let cloudChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(cloudChangeObserver)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func forceSync() {
|
func forceSync() {
|
||||||
cloudSync.sync()
|
cloudSync.sync()
|
||||||
|
refreshSettingsData()
|
||||||
refreshSyncState()
|
refreshSyncState()
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() {
|
func refresh() {
|
||||||
cloudSync.sync()
|
cloudSync.sync()
|
||||||
|
refreshSettingsData()
|
||||||
refreshSyncState()
|
refreshSyncState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +75,7 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
|
|||||||
cloudSync.update { data in
|
cloudSync.update { data in
|
||||||
transform(&data)
|
transform(&data)
|
||||||
}
|
}
|
||||||
|
refreshSettingsData()
|
||||||
refreshSyncState()
|
refreshSyncState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,6 +85,49 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
|
|||||||
syncStatus = cloudSync.syncStatus
|
syncStatus = cloudSync.syncStatus
|
||||||
hasCompletedInitialSync = cloudSync.hasCompletedInitialSync
|
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 {
|
extension SettingsStore {
|
||||||
|
|||||||
194
Andromida/App/Views/Onboarding/NotificationStepView.swift
Normal file
194
Andromida/App/Views/Onboarding/NotificationStepView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import Bedrock
|
|||||||
struct SetupWizardView: View {
|
struct SetupWizardView: View {
|
||||||
@Bindable var store: RitualStore
|
@Bindable var store: RitualStore
|
||||||
@Bindable var categoryStore: CategoryStore
|
@Bindable var categoryStore: CategoryStore
|
||||||
|
let reminderScheduler: ReminderScheduler
|
||||||
let onComplete: () -> Void
|
let onComplete: () -> Void
|
||||||
|
|
||||||
@State private var currentStep: WizardStep = .welcome
|
@State private var currentStep: WizardStep = .welcome
|
||||||
@ -20,13 +21,16 @@ struct SetupWizardView: View {
|
|||||||
@State private var pendingPresets: [RitualPreset] = []
|
@State private var pendingPresets: [RitualPreset] = []
|
||||||
@State private var currentPresetIndex: Int = 0
|
@State private var currentPresetIndex: Int = 0
|
||||||
|
|
||||||
|
@State private var isShowingSkipConfirmation = false
|
||||||
|
|
||||||
enum WizardStep: Int, CaseIterable {
|
enum WizardStep: Int, CaseIterable {
|
||||||
case welcome = 0
|
case welcome = 0
|
||||||
case goalSelection = 1
|
case goalSelection = 1
|
||||||
case timeSelection = 2
|
case timeSelection = 2
|
||||||
case ritualPreview = 3
|
case ritualPreview = 3
|
||||||
case firstCheckIn = 4
|
case firstCheckIn = 4
|
||||||
case whatsNext = 5
|
case notifications = 5
|
||||||
|
case whatsNext = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the user selected "Both" for time preference
|
/// Whether the user selected "Both" for time preference
|
||||||
@ -56,7 +60,7 @@ struct SetupWizardView: View {
|
|||||||
/// Whether to show the back button
|
/// Whether to show the back button
|
||||||
private var canGoBack: Bool {
|
private var canGoBack: Bool {
|
||||||
switch currentStep {
|
switch currentStep {
|
||||||
case .welcome, .firstCheckIn, .whatsNext:
|
case .welcome, .firstCheckIn, .notifications, .whatsNext:
|
||||||
return false
|
return false
|
||||||
case .goalSelection:
|
case .goalSelection:
|
||||||
return true
|
return true
|
||||||
@ -78,8 +82,9 @@ struct SetupWizardView: View {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Header with back button and progress (hidden on welcome and whatsNext)
|
// Header with back button and progress (hidden on welcome, notifications, and whatsNext)
|
||||||
if currentStep != .welcome && currentStep != .whatsNext {
|
// Notifications step has its own "Maybe Later" option so skip button is redundant
|
||||||
|
if currentStep != .welcome && currentStep != .notifications && currentStep != .whatsNext {
|
||||||
headerView
|
headerView
|
||||||
.padding(.top, Design.Spacing.medium)
|
.padding(.top, Design.Spacing.medium)
|
||||||
.padding(.horizontal, Design.Spacing.large)
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
@ -120,10 +125,17 @@ struct SetupWizardView: View {
|
|||||||
store: store,
|
store: store,
|
||||||
ritual: ritual,
|
ritual: ritual,
|
||||||
hasCompletedCheckIn: $hasCompletedFirstCheckIn,
|
hasCompletedCheckIn: $hasCompletedFirstCheckIn,
|
||||||
onComplete: { advanceToWhatsNext() }
|
onComplete: { advanceToNotifications() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case .notifications:
|
||||||
|
NotificationStepView(
|
||||||
|
selectedTime: selectedTime,
|
||||||
|
reminderScheduler: reminderScheduler,
|
||||||
|
onComplete: { advanceToWhatsNext() }
|
||||||
|
)
|
||||||
|
|
||||||
case .whatsNext:
|
case .whatsNext:
|
||||||
WhatsNextStepView(onComplete: onComplete)
|
WhatsNextStepView(onComplete: onComplete)
|
||||||
}
|
}
|
||||||
@ -136,6 +148,14 @@ struct SetupWizardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: Design.Animation.standard), value: currentStep)
|
.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
|
// MARK: - Header View
|
||||||
@ -156,6 +176,11 @@ struct SetupWizardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button(String(localized: "Skip")) {
|
||||||
|
isShowingSkipConfirmation = true
|
||||||
|
}
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(AppTextColors.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress indicator
|
// Progress indicator
|
||||||
@ -190,13 +215,15 @@ struct SetupWizardView: View {
|
|||||||
case .welcome:
|
case .welcome:
|
||||||
return 0.0
|
return 0.0
|
||||||
case .goalSelection:
|
case .goalSelection:
|
||||||
return 0.25
|
return 0.2
|
||||||
case .timeSelection:
|
case .timeSelection:
|
||||||
return 0.5
|
return 0.4
|
||||||
case .ritualPreview:
|
case .ritualPreview:
|
||||||
return 0.7
|
return 0.55
|
||||||
case .firstCheckIn:
|
case .firstCheckIn:
|
||||||
return 0.9
|
return 0.7
|
||||||
|
case .notifications:
|
||||||
|
return 0.85
|
||||||
case .whatsNext:
|
case .whatsNext:
|
||||||
return 1.0
|
return 1.0
|
||||||
}
|
}
|
||||||
@ -227,12 +254,22 @@ struct SetupWizardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func advanceToNotifications() {
|
||||||
|
withAnimation {
|
||||||
|
currentStep = .notifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func advanceToWhatsNext() {
|
private func advanceToWhatsNext() {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
currentStep = .whatsNext
|
currentStep = .whatsNext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func skipOnboarding() {
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Time Selection Handler
|
// MARK: - Time Selection Handler
|
||||||
|
|
||||||
private func handleTimeSelectionContinue() {
|
private func handleTimeSelectionContinue() {
|
||||||
@ -266,7 +303,7 @@ struct SetupWizardView: View {
|
|||||||
hasCompletedFirstCheckIn = false
|
hasCompletedFirstCheckIn = false
|
||||||
|
|
||||||
if presets.isEmpty {
|
if presets.isEmpty {
|
||||||
currentStep = .whatsNext
|
currentStep = .notifications
|
||||||
} else {
|
} else {
|
||||||
currentStep = .ritualPreview
|
currentStep = .ritualPreview
|
||||||
}
|
}
|
||||||
@ -294,8 +331,8 @@ struct SetupWizardView: View {
|
|||||||
// Go to first check-in
|
// Go to first check-in
|
||||||
currentStep = .firstCheckIn
|
currentStep = .firstCheckIn
|
||||||
} else {
|
} else {
|
||||||
// No rituals created, go to what's next
|
// No rituals created, skip to notifications
|
||||||
currentStep = .whatsNext
|
currentStep = .notifications
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -305,6 +342,7 @@ struct SetupWizardView: View {
|
|||||||
SetupWizardView(
|
SetupWizardView(
|
||||||
store: RitualStore.preview,
|
store: RitualStore.preview,
|
||||||
categoryStore: CategoryStore.preview,
|
categoryStore: CategoryStore.preview,
|
||||||
|
reminderScheduler: ReminderScheduler(),
|
||||||
onComplete: {}
|
onComplete: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user