207 lines
7.8 KiB
Swift
207 lines
7.8 KiB
Swift
import SwiftUI
|
|
import Bedrock
|
|
|
|
struct RootView: View {
|
|
@Bindable var store: RitualStore
|
|
@Bindable var settingsStore: SettingsStore
|
|
@Bindable var categoryStore: CategoryStore
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var selectedTab: RootTab
|
|
@State private var analyticsPrewarmTask: Task<Void, Never>?
|
|
@State private var isForegroundRefreshing = false
|
|
@State private var isResumingFromBackground = false
|
|
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
|
|
private let debugForegroundRefreshMinimumSeconds: TimeInterval = 0.8
|
|
private let debugForegroundRefreshKey = "debugForegroundRefreshNextForeground"
|
|
|
|
/// The available tabs in the app.
|
|
enum RootTab: Hashable {
|
|
case today
|
|
case rituals
|
|
case insights
|
|
case history
|
|
case settings
|
|
}
|
|
|
|
/// Creates a RootView with an optional initial tab.
|
|
/// - Parameters:
|
|
/// - store: The ritual store
|
|
/// - settingsStore: The settings store
|
|
/// - categoryStore: The category store
|
|
/// - initialTab: The tab to show on first appearance (defaults to .today)
|
|
init(
|
|
store: RitualStore,
|
|
settingsStore: SettingsStore,
|
|
categoryStore: CategoryStore,
|
|
initialTab: RootTab = .today
|
|
) {
|
|
self.store = store
|
|
self.settingsStore = settingsStore
|
|
self.categoryStore = categoryStore
|
|
self._selectedTab = State(initialValue: initialTab)
|
|
|
|
// Update time-of-day immediately before any views render.
|
|
// This ensures correct rituals are shown when app resumes from background.
|
|
store.updateCurrentTimeOfDay()
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
TabView(selection: $selectedTab) {
|
|
Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) {
|
|
NavigationStack {
|
|
TodayView(store: store, categoryStore: categoryStore)
|
|
.id(store.currentTimeOfDay)
|
|
}
|
|
}
|
|
|
|
Tab(String(localized: "Rituals"), systemImage: "sparkles", value: RootTab.rituals) {
|
|
NavigationStack {
|
|
RitualsView(store: store, categoryStore: categoryStore)
|
|
}
|
|
}
|
|
|
|
Tab(String(localized: "Insights"), systemImage: "chart.bar.fill", value: RootTab.insights) {
|
|
NavigationStack {
|
|
InsightsView(store: store)
|
|
}
|
|
}
|
|
|
|
Tab(String(localized: "History"), systemImage: "calendar", value: RootTab.history) {
|
|
NavigationStack {
|
|
HistoryView(store: store)
|
|
}
|
|
}
|
|
|
|
Tab(String(localized: "Settings"), systemImage: "gearshape.fill", value: RootTab.settings) {
|
|
NavigationStack {
|
|
SettingsView(store: settingsStore, ritualStore: store, categoryStore: categoryStore)
|
|
}
|
|
}
|
|
}
|
|
|
|
if isForegroundRefreshing {
|
|
AppSurface.primary
|
|
.ignoresSafeArea()
|
|
.overlay {
|
|
ProgressView()
|
|
.tint(AppAccent.primary)
|
|
}
|
|
.transition(.opacity)
|
|
}
|
|
|
|
// Brief overlay when resuming to hide stale snapshot
|
|
if isResumingFromBackground {
|
|
AppSurface.primary
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
.tint(AppAccent.primary)
|
|
.background(AppSurface.primary.ignoresSafeArea())
|
|
.optionalAnimation(.easeInOut(duration: 0.12), value: isForegroundRefreshing, reduceMotion: reduceMotion)
|
|
.optionalAnimation(.easeIn(duration: 0.05), value: isResumingFromBackground, reduceMotion: reduceMotion)
|
|
.transaction { transaction in
|
|
if reduceMotion {
|
|
transaction.animation = nil
|
|
}
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
if newPhase == .active {
|
|
store.reminderScheduler.clearBadge()
|
|
|
|
// Update time-of-day immediately (synchronously) before any UI refresh.
|
|
// This ensures the correct rituals are shown without a visible transition.
|
|
store.updateCurrentTimeOfDay()
|
|
|
|
// Hide resume overlay after a tiny delay to allow view to update
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(50))
|
|
isResumingFromBackground = false
|
|
}
|
|
|
|
let useDebugOverlay = UserDefaults.standard.bool(forKey: debugForegroundRefreshKey)
|
|
if useDebugOverlay {
|
|
UserDefaults.standard.set(false, forKey: debugForegroundRefreshKey)
|
|
}
|
|
|
|
// Only show overlay for debug refreshes. Normal foreground refreshes
|
|
// happen silently to avoid jarring transitions when crossing time boundaries.
|
|
refreshAllTabs(
|
|
showOverlay: useDebugOverlay,
|
|
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
|
|
)
|
|
} else if newPhase == .background {
|
|
// Prepare for next resume
|
|
isResumingFromBackground = true
|
|
}
|
|
}
|
|
.onChange(of: selectedTab) { _, _ in
|
|
refreshAllTabs(showOverlay: false, minimumSeconds: foregroundRefreshMinimumSeconds)
|
|
}
|
|
.onChange(of: store.reminderScheduler.shouldNavigateToToday) { _, shouldNavigate in
|
|
if shouldNavigate {
|
|
selectedTab = .today
|
|
store.reminderScheduler.shouldNavigateToToday = false
|
|
}
|
|
}
|
|
.onOpenURL { url in
|
|
handleURL(url)
|
|
}
|
|
}
|
|
|
|
private func handleURL(_ url: URL) {
|
|
guard url.scheme == "andromida" else { return }
|
|
|
|
switch url.host {
|
|
case "today":
|
|
selectedTab = .today
|
|
case "rituals":
|
|
selectedTab = .rituals
|
|
case "insights":
|
|
selectedTab = .insights
|
|
case "history":
|
|
selectedTab = .history
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func refreshAllTabs(showOverlay: Bool, minimumSeconds: TimeInterval) {
|
|
Task { @MainActor in
|
|
let start = Date()
|
|
if showOverlay {
|
|
isForegroundRefreshing = true
|
|
}
|
|
// Let tab selection UI update before refreshing data.
|
|
await Task.yield()
|
|
if showOverlay {
|
|
store.refresh()
|
|
} else {
|
|
store.refreshIfNeeded()
|
|
}
|
|
analyticsPrewarmTask?.cancel()
|
|
analyticsPrewarmTask = Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(350))
|
|
guard !Task.isCancelled else { return }
|
|
store.refreshAnalyticsIfNeeded()
|
|
store.refreshInsightCardsIfNeeded()
|
|
}
|
|
settingsStore.refresh()
|
|
await store.reminderScheduler.refreshStatus()
|
|
if showOverlay {
|
|
let elapsed = Date().timeIntervalSince(start)
|
|
let remaining = max(0, minimumSeconds - elapsed)
|
|
if remaining > 0 {
|
|
try? await Task.sleep(for: .seconds(remaining))
|
|
}
|
|
isForegroundRefreshing = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
RootView(store: RitualStore.preview, settingsStore: SettingsStore.preview, categoryStore: CategoryStore.preview)
|
|
}
|