MainTabView.swift 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  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. private var orderedItems: [TabItem] {
  17. Storage.shared.orderedTabBarItems()
  18. }
  19. var body: some View {
  20. // The banner sits in a VStack above the TabView (rather than a
  21. // .safeAreaInset) so the UIKit-hosted Home content is physically
  22. // pushed down too — safe-area changes don't propagate into
  23. // UIViewControllerRepresentable.
  24. VStack(spacing: 0) {
  25. if let message = activeBanner.value {
  26. AppBannerView(message: message)
  27. .transition(.move(edge: .top).combined(with: .opacity))
  28. }
  29. tabView
  30. }
  31. .animation(.easeInOut(duration: 0.25), value: activeBanner.value)
  32. }
  33. private var tabView: some View {
  34. TabView(selection: $selectedTab.value) {
  35. ForEach(Array(orderedItems.prefix(4).enumerated()), id: \.element) { index, item in
  36. tabContent(for: item)
  37. .tabItem {
  38. Label(item.displayName, systemImage: item.icon)
  39. }
  40. .tag(index)
  41. }
  42. NavigationStack {
  43. MoreMenuView()
  44. }
  45. .tabItem {
  46. Label("Menu", systemImage: "line.3.horizontal")
  47. }
  48. .tag(4)
  49. }
  50. .preferredColorScheme(appearanceMode.value.colorScheme)
  51. .onAppear {
  52. // Start the data pipeline as soon as the UI appears, independent of
  53. // tab layout. Without this, a user who moves Home into the Menu would
  54. // have no MainViewController — and therefore no data fetching, alarms,
  55. // or background audio — until they manually opened Home. Tying it to
  56. // onAppear (not app launch) keeps it off the BG-only refresh path.
  57. MainViewController.bootstrap()
  58. // One-time consent prompt. Previously presented by SceneDelegate,
  59. // which was removed in the storyboard→SwiftUI migration; without
  60. // this, fresh installs stay permanently undecided and telemetry
  61. // never sends. The storage flag keeps it to a single appearance.
  62. if !Storage.shared.telemetryConsentDecisionMade.value {
  63. showTelemetryConsent = true
  64. }
  65. }
  66. .sheet(isPresented: $showTelemetryConsent) {
  67. // User must explicitly choose — no swipe-to-dismiss.
  68. TelemetryConsentView()
  69. .interactiveDismissDisabled(true)
  70. }
  71. }
  72. @ViewBuilder
  73. private func tabContent(for item: TabItem) -> some View {
  74. switch item {
  75. case .home:
  76. HomeContentView()
  77. case .alarms:
  78. AlarmsContainerView()
  79. case .remote:
  80. RemoteContentView()
  81. case .nightscout:
  82. NightscoutContentView()
  83. case .snoozer:
  84. SnoozerView()
  85. case .treatments:
  86. TreatmentsView()
  87. case .stats:
  88. NavigationStack {
  89. AggregatedStatsContentView(mainViewController: MainViewController.shared)
  90. }
  91. }
  92. }
  93. }