Andromida/Andromida/App/Views/RootView.swift
Matt Bruce b5c351f313 accessibility motion support
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2026-02-09 08:42:26 -06:00

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)
}