MainTabView.swift 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. // LoopFollow
  2. // MainTabView.swift
  3. import SwiftUI
  4. struct MainTabView: View {
  5. @ObservedObject private var selectedTab = Observable.shared.selectedTabIndex
  6. @ObservedObject private var appearanceMode = Storage.shared.appearanceMode
  7. @ObservedObject private var homePosition = Storage.shared.homePosition
  8. @ObservedObject private var alarmsPosition = Storage.shared.alarmsPosition
  9. @ObservedObject private var remotePosition = Storage.shared.remotePosition
  10. @ObservedObject private var nightscoutPosition = Storage.shared.nightscoutPosition
  11. @ObservedObject private var snoozerPosition = Storage.shared.snoozerPosition
  12. @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition
  13. @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition
  14. @ObservedObject private var activeBanner = Observable.shared.activeBanner
  15. @State private var showTelemetryConsent = false
  16. @State private var showOnboarding = false
  17. private var orderedItems: [TabItem] {
  18. Storage.shared.orderedTabBarItems()
  19. }
  20. var body: some View {
  21. // The banner sits in a VStack above the TabView (rather than a
  22. // .safeAreaInset) so the UIKit-hosted Home content is physically
  23. // pushed down too — safe-area changes don't propagate into
  24. // UIViewControllerRepresentable.
  25. VStack(spacing: 0) {
  26. if let message = activeBanner.value {
  27. AppBannerView(message: message)
  28. .transition(.move(edge: .top).combined(with: .opacity))
  29. }
  30. tabView
  31. }
  32. .animation(.easeInOut(duration: 0.25), value: activeBanner.value)
  33. }
  34. private var tabView: some View {
  35. TabView(selection: $selectedTab.value) {
  36. ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in
  37. tabContent(for: item)
  38. .tabItem {
  39. Label(item.displayName, systemImage: item.icon)
  40. }
  41. .tag(index)
  42. }
  43. NavigationStack {
  44. MoreMenuView()
  45. }
  46. .tabItem {
  47. Label("Menu", systemImage: "line.3.horizontal")
  48. }
  49. .tag(4)
  50. }
  51. .preferredColorScheme(appearanceMode.value.colorScheme)
  52. .onAppear {
  53. // Start the data pipeline as soon as the UI appears, independent of
  54. // tab layout. Without this, a user who moves Home into the Menu would
  55. // have no MainViewController — and therefore no data fetching, alarms,
  56. // or background audio — until they manually opened Home. Tying it to
  57. // onAppear (not app launch) keeps it off the BG-only refresh path.
  58. MainViewController.bootstrap()
  59. // Show the first-run onboarding once for everyone. Returning users
  60. // get a prominent Skip on the welcome screen. The telemetry consent
  61. // prompt is deferred until onboarding is dismissed so the two never
  62. // appear on top of one another.
  63. if !Storage.shared.hasCompletedOnboarding.value {
  64. Observable.shared.isOnboardingActive.value = true
  65. showOnboarding = true
  66. } else {
  67. runPostOnboardingPrompts()
  68. }
  69. }
  70. .fullScreenCover(isPresented: $showOnboarding, onDismiss: {
  71. Observable.shared.isOnboardingActive.value = false
  72. // Covers both finishing and skipping onboarding — the telemetry and
  73. // notification steps live inside the flow, so anyone who skips still
  74. // needs these handled here.
  75. runPostOnboardingPrompts()
  76. }) {
  77. OnboardingContainerView(onClose: { showOnboarding = false })
  78. }
  79. .sheet(isPresented: $showTelemetryConsent, onDismiss: {
  80. // Ask for notifications only once telemetry is resolved, so the system
  81. // prompt never stacks on top of the consent sheet.
  82. requestNotificationsIfAlarmsEnabled()
  83. }) {
  84. // User must explicitly choose — no swipe-to-dismiss.
  85. TelemetryConsentView()
  86. .interactiveDismissDisabled(true)
  87. }
  88. }
  89. /// Runs after onboarding closes, whether it was completed or skipped. Telemetry
  90. /// consent and notification permission both live inside the onboarding flow, so
  91. /// a skip would otherwise bypass them. Telemetry consent goes first (as a
  92. /// sheet); the notification request follows on its dismissal so the two never
  93. /// appear at once. When the user completed the flow these are already decided,
  94. /// so both calls are no-ops.
  95. private func runPostOnboardingPrompts() {
  96. if !Storage.shared.telemetryConsentDecisionMade.value {
  97. showTelemetryConsent = true // notifications requested on its dismiss
  98. } else {
  99. requestNotificationsIfAlarmsEnabled()
  100. }
  101. }
  102. /// Deferred-permission policy: only ask for notifications once there's an
  103. /// enabled alarm that needs them. Safe to call repeatedly — it's a no-op once
  104. /// the status is determined.
  105. private func requestNotificationsIfAlarmsEnabled() {
  106. if Storage.shared.alarms.value.contains(where: { $0.isEnabled }) {
  107. NotificationAuthorization.requestIfNeeded()
  108. }
  109. }
  110. @ViewBuilder
  111. private func tabContent(for item: TabItem) -> some View {
  112. switch item {
  113. case .home:
  114. HomeContentView()
  115. case .alarms:
  116. AlarmsContainerView()
  117. case .remote:
  118. RemoteContentView()
  119. case .nightscout:
  120. NightscoutContentView()
  121. case .snoozer:
  122. SnoozerView()
  123. case .treatments:
  124. TreatmentsView()
  125. case .stats:
  126. NavigationStack {
  127. AggregatedStatsContentView(mainViewController: MainViewController.shared)
  128. }
  129. }
  130. }
  131. }