diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 035544902..5a1a7f581 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -197,14 +197,14 @@ DDC7E5472DBD8A1600EB1127 /* AlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */; }; DDC7E5CF2DC77C2000EB1127 /* SnoozerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */; }; DDCC3A4B2DDBB5E4006F1C10 /* BatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */; }; - DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A4D2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */; }; - DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCC3A4F2DDC5B54006F1C10 /* BatteryDropCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */; }; + DDCC3A502DDED000006F1C10 /* PumpBatteryCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */; }; DDCC3A542DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */; }; DDCC3A562DDC9617006F1C10 /* MissedBolusCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */; }; DDCC3A582DDC9655006F1C10 /* MissedBolusAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */; }; DDCC3A5A2DDC988F006F1C10 /* CarbSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A592DDC988F006F1C10 /* CarbSample.swift */; }; + DDCC3A5B2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */; }; DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF979324C0D380002C9752 /* UIViewExtension.swift */; }; DDCF9A802D85FD0B004DF4DD /* Alarm.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A7F2D85FD09004DF4DD /* Alarm.swift */; }; DDCF9A822D85FD15004DF4DD /* AlarmType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCF9A812D85FD14004DF4DD /* AlarmType.swift */; }; @@ -248,6 +248,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */; }; DDFF3D872D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */; }; DDFF3D892D1429AB00BF9D9E /* BackgroundRefreshType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */; }; + E2D4632C2F2063DD003D7CB4 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D4632B2F2063DD003D7CB4 /* AboutView.swift */; }; FC16A97A24996673003D6245 /* NightScout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97924996673003D6245 /* NightScout.swift */; }; FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7CE589248ABEA3001F83B8 /* AlarmSound.swift */; }; FC16A97D24996747003D6245 /* SpeakBG.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC16A97C24996747003D6245 /* SpeakBG.swift */; }; @@ -599,14 +600,14 @@ DDC7E5402DBD8A1600EB1127 /* AlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEditor.swift; sourceTree = ""; }; DDC7E5CE2DC77C2000EB1127 /* SnoozerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozerViewModel.swift; sourceTree = ""; }; DDCC3A4A2DDBB5E4006F1C10 /* BatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCondition.swift; sourceTree = ""; }; - DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A4C2DDBB77C006F1C10 /* PhoneBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneBatteryAlarmEditor.swift; sourceTree = ""; }; - DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3A4E2DDC5B54006F1C10 /* BatteryDropCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropCondition.swift; sourceTree = ""; }; + DDCC3A512DDED000006F1C10 /* PumpBatteryCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryCondition.swift; sourceTree = ""; }; DDCC3A532DDC5D62006F1C10 /* BatteryDropAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDropAlarmEditor.swift; sourceTree = ""; }; DDCC3A552DDC9617006F1C10 /* MissedBolusCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusCondition.swift; sourceTree = ""; }; DDCC3A572DDC9655006F1C10 /* MissedBolusAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedBolusAlarmEditor.swift; sourceTree = ""; }; DDCC3A592DDC988F006F1C10 /* CarbSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbSample.swift; sourceTree = ""; }; + DDCC3A5C2DDE2000006F1C10 /* PumpBatteryAlarmEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpBatteryAlarmEditor.swift; sourceTree = ""; }; DDCC3ABF2DDE10B0006F1C10 /* Testing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Testing.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/Testing.framework; sourceTree = DEVELOPER_DIR; }; DDCC3AD62DDE1790006F1C10 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DDCF979324C0D380002C9752 /* UIViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewExtension.swift; sourceTree = ""; }; @@ -652,6 +653,7 @@ DDFF3D842D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsView.swift; sourceTree = ""; }; DDFF3D862D14280500BF9D9E /* BackgroundRefreshSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshSettingsViewModel.swift; sourceTree = ""; }; DDFF3D882D1429AB00BF9D9E /* BackgroundRefreshType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshType.swift; sourceTree = ""; }; + E2D4632B2F2063DD003D7CB4 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.debug.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.debug.xcconfig"; sourceTree = ""; }; FC16A97924996673003D6245 /* NightScout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightScout.swift; sourceTree = ""; }; FC16A97C24996747003D6245 /* SpeakBG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeakBG.swift; sourceTree = ""; }; @@ -848,6 +850,7 @@ 6589CC612E9E7D1600BB18FE /* Settings */ = { isa = PBXGroup; children = ( + E2D4632B2F2063DD003D7CB4 /* AboutView.swift */, 6589CC552E9E7D1600BB18FE /* ImportExport */, 6589CC562E9E7D1600BB18FE /* AdvancedSettingsView.swift */, 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */, @@ -1921,6 +1924,7 @@ DD48781E2C7DAF2F0048F05C /* PushNotificationManager.swift in Sources */, DD0B9D582DE1F3B20090C337 /* AlarmType+canAcknowledge.swift in Sources */, DD7F4BC52DD3CE0700D449E9 /* AlarmBGLimitSection.swift in Sources */, + E2D4632C2F2063DD003D7CB4 /* AboutView.swift in Sources */, DD7F4B9F2DD1F92700D449E9 /* AlarmActiveSection.swift in Sources */, DD4AFB672DB68C5500BB593F /* UUID+Identifiable.swift in Sources */, DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */, @@ -2221,7 +2225,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2248,7 +2252,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2301,6 +2305,7 @@ COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2364,6 +2369,7 @@ COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; diff --git a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f2d75025c..0d4f32ed0 100644 --- a/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LoopFollow.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,16 +1,96 @@ { - "object": { - "pins": [ - { - "package": "Oxygen", - "repositoryURL": "https://github.com/mpangburn/Oxygen.git", - "state": { - "branch": "master", - "revision": "b3c7a6ead1400e4799b16755d23c9905040d4acc", - "version": null - } - } - ] - }, - "version": 1 + "originHash" : "94a024be279d128a7e82f3c76785db1e4cf7c9380d0c4aa59dfdf54952403b8d", + "pins" : [ + { + "identity" : "bluecryptor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kitura/BlueCryptor.git", + "state" : { + "revision" : "cec97c24b111351e70e448972a7d3fe68a756d6d", + "version" : "2.0.2" + } + }, + { + "identity" : "blueecc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kitura/BlueECC.git", + "state" : { + "revision" : "1485268a54f8135435a825a855e733f026fa6cc8", + "version" : "1.2.201" + } + }, + { + "identity" : "bluersa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kitura/BlueRSA.git", + "state" : { + "revision" : "f40325520344a966523b214394aa350132a6af68", + "version" : "1.0.203" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", + "version" : "1.9.0" + } + }, + { + "identity" : "kituracontracts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kitura/KituraContracts.git", + "state" : { + "revision" : "6edf7ac3dd2b3a2c61284778d430bbad7d8a6f23", + "version" : "2.0.1" + } + }, + { + "identity" : "loggerapi", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kitura/LoggerAPI.git", + "state" : { + "revision" : "4e6b45e850ffa275e8e26a24c6454fd709d5b6ac", + "version" : "2.0.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-jwt", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Kitura/Swift-JWT.git", + "state" : { + "revision" : "f68ec28fbd90a651597e9e825ea7f315f8d52a1f", + "version" : "4.0.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "7ee16e465622412764b0ff0c1301801dc71b8f61", + "version" : "1.9.0" + } + } + ], + "version" : 3 } diff --git a/LoopFollow/Alarm/AlarmSettingsView.swift b/LoopFollow/Alarm/AlarmSettingsView.swift index 3f328bfa4..88ef9a345 100644 --- a/LoopFollow/Alarm/AlarmSettingsView.swift +++ b/LoopFollow/Alarm/AlarmSettingsView.swift @@ -45,153 +45,145 @@ struct AlarmSettingsView: View { } var body: some View { - NavigationView { - Form { - Section( - header: Text("Snooze & Mute Options"), - footer: Text(""" - “Snooze All” disables every alarm. \ - “Mute All” silences phone sounds but still vibrates \ - and shows iOS notifications. - """) - ) { - Toggle("All Alerts Snoozed", isOn: Binding( - get: { - if let until = cfgStore.value.snoozeUntil { return until > Date() } - return false - }, - set: { on in - if on { - let target = cfgStore.value.snoozeUntil ?? Date() - if target <= Date() { - cfgStore.value.snoozeUntil = Date().addingTimeInterval(3600) - } - } else { - cfgStore.value.snoozeUntil = nil + Form { + Section { + Toggle("All Alerts Snoozed", isOn: Binding( + get: { + if let until = cfgStore.value.snoozeUntil { return until > Date() } + return false + }, + set: { on in + if on { + let target = cfgStore.value.snoozeUntil ?? Date() + if target <= Date() { + cfgStore.value.snoozeUntil = Date().addingTimeInterval(3600) } + } else { + cfgStore.value.snoozeUntil = nil } - )) - - if let until = cfgStore.value.snoozeUntil, until > Date() { - DatePicker( - "Until", - selection: optDateBinding( - Binding( - get: { cfgStore.value.snoozeUntil }, - set: { cfgStore.value.snoozeUntil = $0 } - ) - ), - displayedComponents: [.date, .hourAndMinute] - ) - .datePickerStyle(.compact) } + )) - Toggle("All Sounds Muted", isOn: Binding( - get: { - if let until = cfgStore.value.muteUntil { return until > Date() } - return false - }, - set: { on in - if on { - let target = cfgStore.value.muteUntil ?? Date() - if target <= Date() { - cfgStore.value.muteUntil = Date().addingTimeInterval(3600) - } - } else { - cfgStore.value.muteUntil = nil - } - } - )) - - if let until = cfgStore.value.muteUntil, until > Date() { - DatePicker( - "Until", - selection: optDateBinding( - Binding( - get: { cfgStore.value.muteUntil }, - set: { cfgStore.value.muteUntil = $0 } - ) - ), - displayedComponents: [.date, .hourAndMinute] - ) - .datePickerStyle(.compact) - } - } - - Section( - header: Text("Day / Night Schedule"), - footer: Text("Pick when your day period begins and when your night period begins. " + - "Any time from your Day-starts time up until your Night-starts time will count as day; " + - "from Night-starts until the next Day-starts will count as night.") - ) { + if let until = cfgStore.value.snoozeUntil, until > Date() { DatePicker( - "Day starts", - selection: dayBinding, - displayedComponents: [.hourAndMinute] + "Until", + selection: optDateBinding( + Binding( + get: { cfgStore.value.snoozeUntil }, + set: { cfgStore.value.snoozeUntil = $0 } + ) + ), + displayedComponents: [.date, .hourAndMinute] ) .datePickerStyle(.compact) + } + Toggle("All Sounds Muted", isOn: Binding( + get: { + if let until = cfgStore.value.muteUntil { return until > Date() } + return false + }, + set: { on in + if on { + let target = cfgStore.value.muteUntil ?? Date() + if target <= Date() { + cfgStore.value.muteUntil = Date().addingTimeInterval(3600) + } + } else { + cfgStore.value.muteUntil = nil + } + } + )) + + if let until = cfgStore.value.muteUntil, until > Date() { DatePicker( - "Night starts", - selection: nightBinding, - displayedComponents: [.hourAndMinute] + "Until", + selection: optDateBinding( + Binding( + get: { cfgStore.value.muteUntil }, + set: { cfgStore.value.muteUntil = $0 } + ) + ), + displayedComponents: [.date, .hourAndMinute] ) .datePickerStyle(.compact) } + } header: { + Label("Snooze & Mute Options", systemImage: "moon.zzz") + } footer: { + Text(""" + "Snooze All" disables every alarm. \ + "Mute All" silences phone sounds but still vibrates \ + and shows iOS notifications. + """) + } - Section(header: Text("Alarm Settings")) { - Toggle( - "Override System Volume", - isOn: Binding( - get: { cfgStore.value.overrideSystemOutputVolume }, - set: { cfgStore.value.overrideSystemOutputVolume = $0 } - ) - ) + Section { + DatePicker( + "Day starts", + selection: dayBinding, + displayedComponents: [.hourAndMinute] + ) + .datePickerStyle(.compact) + + DatePicker( + "Night starts", + selection: nightBinding, + displayedComponents: [.hourAndMinute] + ) + .datePickerStyle(.compact) + } header: { + Label("Day / Night Schedule", systemImage: "sun.and.horizon") + } footer: { + Text("Pick when your day period begins and when your night period begins. " + + "Any time from your Day-starts time up until your Night-starts time will count as day; " + + "from Night-starts until the next Day-starts will count as night.") + } - if cfgStore.value.overrideSystemOutputVolume { - Stepper( - "Volume Level: \(Int(cfgStore.value.forcedOutputVolume * 100))%", - value: Binding( - get: { Double(cfgStore.value.forcedOutputVolume) }, - set: { cfgStore.value.forcedOutputVolume = Float($0) } - ), - in: 0 ... 1, - step: 0.05 - ) + Section { + Toggle( + "Override System Volume", + isOn: $cfgStore.value.binding(\.overrideSystemOutputVolume) + ) + + if cfgStore.value.overrideSystemOutputVolume { + HStack { + Text("Volume Level") + Spacer() + Text("\(Int(cfgStore.value.forcedOutputVolume * 100))%") + .foregroundColor(.secondary) + Stepper("", + value: Binding( + get: { Double(cfgStore.value.forcedOutputVolume) }, + set: { cfgStore.value.forcedOutputVolume = Float($0) } + ), + in: 0 ... 1, + step: 0.05) + .labelsHidden() } - - Toggle( - "Audio During Calls", - isOn: Binding( - get: { cfgStore.value.audioDuringCalls }, - set: { cfgStore.value.audioDuringCalls = $0 } - ) - ) - - Toggle( - "Ignore Zero BG", - isOn: Binding( - get: { cfgStore.value.ignoreZeroBG }, - set: { cfgStore.value.ignoreZeroBG = $0 } - ) - ) - - Toggle( - "Auto‑Snooze CGM Start", - isOn: Binding( - get: { cfgStore.value.autoSnoozeCGMStart }, - set: { cfgStore.value.autoSnoozeCGMStart = $0 } - ) - ) - - Toggle( - "Volume Buttons Snooze Alarms", - isOn: Binding( - get: { cfgStore.value.enableVolumeButtonSnooze }, - set: { cfgStore.value.enableVolumeButtonSnooze = $0 } - ) - ) } + + Toggle( + "Audio During Calls", + isOn: $cfgStore.value.binding(\.audioDuringCalls) + ) + + Toggle( + "Ignore Zero BG", + isOn: $cfgStore.value.binding(\.ignoreZeroBG) + ) + + Toggle( + "Auto‑Snooze CGM Start", + isOn: $cfgStore.value.binding(\.autoSnoozeCGMStart) + ) + + Toggle( + "Volume Buttons Snooze Alarms", + isOn: $cfgStore.value.binding(\.enableVolumeButtonSnooze) + ) + } header: { + Label("Alarm Settings", systemImage: "bell.badge") } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift index a50bc6ea9..aba422e83 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -11,24 +11,22 @@ struct BackgroundRefreshSettingsView: View { @ObservedObject var bleManager = BLEManager.shared var body: some View { - NavigationView { - Form { - refreshTypeSection + Form { + refreshTypeSection - if viewModel.backgroundRefreshType.isBluetooth { - selectedDeviceSection - availableDevicesSection - } - } - .onAppear { - startTimer() - } - .onDisappear { - stopTimer() + if viewModel.backgroundRefreshType.isBluetooth { + selectedDeviceSection + availableDevicesSection } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Background Refresh Settings", displayMode: .inline) + .onAppear { + startTimer() + } + .onDisappear { + stopTimer() + } } // MARK: - Subviews / Computed Properties @@ -40,38 +38,38 @@ struct BackgroundRefreshSettingsView: View { Text(type.rawValue).tag(type) } } - .pickerStyle(MenuPickerStyle()) + .pickerStyle(.menu) VStack(alignment: .leading, spacing: 4) { Text("Adjust the background refresh type.") .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) switch viewModel.backgroundRefreshType { case .none: Text("No background refresh. Alarms and updates will not work unless the app is open in the foreground.") .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) case .silentTune: Text("A silent tune will play in the background, keeping the app active. May be interrupted by other apps. Allows continuous updates but consumes more battery.") .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) case .rileyLink: Text("Requires a RileyLink-compatible device within Bluetooth range. Provides updates once per minute and uses less battery than the silent tune method.") .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) case .dexcom: Text("Requires a Dexcom G6/ONE/G7/ONE+ transmitter within Bluetooth range. Provides updates every 5 minutes and uses less battery than the silent tune method. If you have more than one device to choose from, select the one with the smallest expected bg delay.") .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) case .omnipodDash: Text("Requires an OmniPod DASH pod paired with this device within Bluetooth range. Provides updates once every 3 minutes and uses less battery than the silent tune method.") .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } } @@ -80,7 +78,7 @@ struct BackgroundRefreshSettingsView: View { @ViewBuilder private var selectedDeviceSection: some View { if let storedDevice = bleManager.getSelectedDevice() { - Section(header: Text("Selected Device")) { + Section { VStack(alignment: .leading, spacing: 4) { Text(storedDevice.name ?? "Unknown Device") .font(.headline) @@ -89,12 +87,12 @@ struct BackgroundRefreshSettingsView: View { if storedDevice.rssi != 0 { Text("RSSI: \(storedDevice.rssi) dBm") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .font(.footnote) } if let offset = BLEManager.shared.expectedSensorFetchOffsetString(for: storedDevice) { Text("Expected bg delay: \(offset)") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .font(.footnote) } @@ -104,13 +102,15 @@ struct BackgroundRefreshSettingsView: View { bleManager.disconnect() }) { Text("Disconnect") - .foregroundColor(.blue) + .foregroundStyle(.blue) } .buttonStyle(BorderlessButtonStyle()) Spacer() } } .padding(.vertical, 8) + } header: { + Label("Selected Device", systemImage: "checkmark.circle") } .id(forceRefresh) } @@ -127,7 +127,7 @@ struct BackgroundRefreshSettingsView: View { } private var availableDevicesSection: some View { - Section(header: scanningStatusHeader) { + Section { BLEDeviceSelectionView( bleManager: bleManager, selectedFilter: viewModel.backgroundRefreshType, @@ -135,13 +135,15 @@ struct BackgroundRefreshSettingsView: View { bleManager.connect(device: device) } ) + } header: { + scanningStatusHeader } } private var scanningStatusHeader: some View { Text("Scanning for \(viewModel.backgroundRefreshType.rawValue)...") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } private func deviceConnectionStatus(for device: BLEDevice) -> some View { @@ -151,28 +153,28 @@ struct BackgroundRefreshSettingsView: View { if device.isConnected { return Text("Connected") - .foregroundColor(.green) + .foregroundStyle(.green) } else if let lastConnected = device.lastConnected { let timeRatio = timeSinceLastConnection / expectedConnectionTime let timeString = formattedTimeString(from: timeSinceLastConnection) if timeRatio < 1.0 { return Text("Disconnected for \(timeString)") - .foregroundColor(.green) + .foregroundStyle(.green) } else if timeRatio <= 1.15 { return Text("Disconnected for \(timeString)") - .foregroundColor(.orange) + .foregroundStyle(.orange) } else if timeRatio <= 3.0 { return Text("Disconnected for \(timeString)") - .foregroundColor(.red) + .foregroundStyle(.red) } else { let date = dateTimeUtils.formattedDate(from: lastConnected) return Text("Last connection: \(date)") - .foregroundColor(.red) + .foregroundStyle(.red) } } else { return Text("Reconnecting...") - .foregroundColor(.orange) + .foregroundStyle(.orange) } } diff --git a/LoopFollow/Helpers/Views/NavigationRow.swift b/LoopFollow/Helpers/Views/NavigationRow.swift index be38ebfe4..8b8f459ec 100644 --- a/LoopFollow/Helpers/Views/NavigationRow.swift +++ b/LoopFollow/Helpers/Views/NavigationRow.swift @@ -23,3 +23,50 @@ struct NavigationRow: View { .buttonStyle(.plain) } } + +// MARK: - Settings View Modifier + +struct SettingsStyleModifier: ViewModifier { + let title: String + + func body(content: Content) -> some View { + content + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) + } +} + +extension View { + /// Applies standard styling for settings views: + /// - Sets the navigation title with inline display mode + /// - Applies dark mode preference if enabled in settings + func settingsStyle(title: String) -> some View { + modifier(SettingsStyleModifier(title: title)) + } +} + +// MARK: - Binding Helpers + +extension Binding { + /// Creates a binding that accesses a property on the wrapped value using a keypath. + /// Useful for creating bindings to nested properties in storage objects. + /// + /// Example: + /// ``` + /// // Instead of: + /// Toggle("Override", isOn: Binding( + /// get: { cfgStore.value.overrideVolume }, + /// set: { cfgStore.value.overrideVolume = $0 } + /// )) + /// + /// // Use: + /// Toggle("Override", isOn: $cfgStore.value.binding(\.overrideVolume)) + /// ``` + func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { self.wrappedValue[keyPath: keyPath] }, + set: { self.wrappedValue[keyPath: keyPath] = $0 } + ) + } +} diff --git a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift index 400d3ed77..47d4dd25b 100644 --- a/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift +++ b/LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift @@ -7,42 +7,40 @@ struct InfoDisplaySettingsView: View { @ObservedObject var viewModel: InfoDisplaySettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("General")) { - Toggle(isOn: Binding( - get: { Storage.shared.hideInfoTable.value }, - set: { Storage.shared.hideInfoTable.value = $0 } - )) { - Text("Hide Information Table") - } + Form { + Section("General") { + Toggle(isOn: Binding( + get: { Storage.shared.hideInfoTable.value }, + set: { Storage.shared.hideInfoTable.value = $0 } + )) { + Text("Hide Information Table") } + } - Section(header: Text("Information Display Settings")) { - List { - ForEach(viewModel.infoSort, id: \.self) { sortedIndex in - HStack { - Text(viewModel.getName(for: sortedIndex)) - Spacer() - Toggle("", isOn: Binding( - get: { viewModel.infoVisible[sortedIndex] }, - set: { _ in - viewModel.toggleVisibility(for: sortedIndex) - } - )) - .labelsHidden() - } + Section("Information Display Settings") { + List { + ForEach(viewModel.infoSort, id: \.self) { sortedIndex in + HStack { + Text(viewModel.getName(for: sortedIndex)) + Spacer() + Toggle("", isOn: Binding( + get: { viewModel.infoVisible[sortedIndex] }, + set: { _ in + viewModel.toggleVisibility(for: sortedIndex) + } + )) + .labelsHidden() } - .onMove(perform: viewModel.move) } - .environment(\.editMode, .constant(.active)) + .onMove(perform: viewModel.move) } - } - .onDisappear { - NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + .environment(\.editMode, .constant(.active)) } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Information Display Settings", displayMode: .inline) + .onDisappear { + NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil) + } } } diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index 7c08e756f..4e8e7b98e 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -7,16 +7,14 @@ struct NightscoutSettingsView: View { @ObservedObject var viewModel: NightscoutSettingsViewModel var body: some View { - NavigationView { - Form { - urlSection - tokenSection - statusSection - importSection - } - .onDisappear { - viewModel.dismiss() - } + Form { + urlSection + tokenSection + statusSection + importSection + } + .onDisappear { + viewModel.dismiss() } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Nightscout Settings", displayMode: .inline) @@ -25,7 +23,7 @@ struct NightscoutSettingsView: View { // MARK: - Subviews / Computed Properties private var urlSection: some View { - Section(header: Text("URL")) { + Section { TextField("Enter URL", text: $viewModel.nightscoutURL) .textContentType(.username) .autocapitalization(.none) @@ -33,11 +31,15 @@ struct NightscoutSettingsView: View { .onChange(of: viewModel.nightscoutURL) { newValue in viewModel.processURL(newValue) } + } header: { + Label("URL", systemImage: "globe") + } footer: { + Text("Enter your Nightscout site URL (e.g., https://yoursite.herokuapp.com or https://yoursite.ns.10be.de).") } } private var tokenSection: some View { - Section(header: Text("Token")) { + Section { HStack { Text("Access Token") TogglableSecureInput( @@ -47,24 +49,32 @@ struct NightscoutSettingsView: View { textContentType: .password ) } + } header: { + Label("Token", systemImage: "key") + } footer: { + Text("Optional: Enter an access token if your Nightscout site requires authentication.") } } private var statusSection: some View { - Section(header: Text("Status")) { + Section { Text(viewModel.nightscoutStatus) + } header: { + Label("Status", systemImage: "checkmark.circle") } } private var importSection: some View { - Section(header: Text("Import Settings")) { + Section { NavigationLink(destination: ImportExportSettingsView()) { HStack { Image(systemName: "square.and.arrow.down") - .foregroundColor(.blue) + .foregroundStyle(.blue) Text("Import Settings from QR Code") } } + } header: { + Label("Import Settings", systemImage: "square.and.arrow.down") } } } diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 1c1066197..69e8dad53 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -60,7 +60,11 @@ struct RemoteSettingsView: View { Text("Nightscout should be used for Trio 0.2.x.") .font(.footnote) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) + } header: { + Label("Remote Type", systemImage: "antenna.radiowaves.left.and.right") + } footer: { + Text("Select how you want to send remote commands. Loop Remote Control uses APNS for Loop apps. Trio Remote Control uses APNS for Trio. Nightscout uses the Nightscout careportal.") } // MARK: - Import/Export Settings Section @@ -69,26 +73,30 @@ struct RemoteSettingsView: View { NavigationLink(destination: ImportExportSettingsView()) { HStack { Image(systemName: "square.and.arrow.down") - .foregroundColor(.blue) + .foregroundStyle(.blue) Text("Import/Export Settings") Spacer() Image(systemName: "chevron.right") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .font(.caption) } } .buttonStyle(.plain) + } header: { + Label("Import/Export", systemImage: "square.and.arrow.up.on.square") } // MARK: - Meal Section (for TRC only) if viewModel.remoteType == .trc { - Section(header: Text("Meal Settings")) { + Section { Toggle("Meal with Bolus", isOn: $viewModel.mealWithBolus) - .toggleStyle(SwitchToggleStyle()) + .toggleStyle(.switch) Toggle("Meal with Fat/Protein", isOn: $viewModel.mealWithFatProtein) - .toggleStyle(SwitchToggleStyle()) + .toggleStyle(.switch) + } header: { + Label("Meal Settings", systemImage: "fork.knife") } } @@ -99,7 +107,7 @@ struct RemoteSettingsView: View { } if !Storage.shared.bolusIncrementDetected.value { - Section(header: Text("Bolus Increment")) { + Section { HStack { Text("Increment") Spacer() @@ -116,15 +124,17 @@ struct RemoteSettingsView: View { ) .frame(width: 100) Text("U") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } + } header: { + Label("Bolus Increment", systemImage: "syringe") } } // MARK: - User Information Section if viewModel.remoteType != .none && viewModel.remoteType != .loopAPNS { - Section(header: Text("User Information")) { + Section { HStack { Text("User") TextField("Enter User", text: $viewModel.user) @@ -132,13 +142,15 @@ struct RemoteSettingsView: View { .disableAutocorrection(true) .multilineTextAlignment(.trailing) } + } header: { + Label("User Information", systemImage: "person") } } // MARK: - Trio Remote Control Settings if viewModel.remoteType == .trc { - Section(header: Text("Trio Remote Control Settings")) { + Section { HStack { Text("Shared Secret") TogglableSecureInput( @@ -166,11 +178,13 @@ struct RemoteSettingsView: View { ) .frame(minHeight: 110) } + } header: { + Label("Trio Remote Control Settings", systemImage: "antenna.radiowaves.left.and.right") } // MARK: - Debug / Info - Section(header: Text("Debug / Info")) { + Section { Text("Device Token: \(Storage.shared.deviceToken.value)") Text("APNS Environment: \(Storage.shared.productionEnvironment.value ? "Production" : "Development")") Text("Team ID: \(Storage.shared.teamId.value ?? "")") @@ -178,13 +192,15 @@ struct RemoteSettingsView: View { if Storage.shared.bolusIncrementDetected.value { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } + } header: { + Label("Debug / Info", systemImage: "ladybug") } } // MARK: - Loop APNS Settings if viewModel.remoteType == .loopAPNS { - Section(header: Text("Loop APNS Configuration")) { + Section { HStack { Text("Developer Team ID") TogglableSecureInput( @@ -237,23 +253,25 @@ struct RemoteSettingsView: View { Text("Environment") Spacer() Toggle("Production", isOn: $viewModel.productionEnvironment) - .toggleStyle(SwitchToggleStyle()) + .toggleStyle(.switch) } Text("Production is used for browser builders and should be switched off for Xcode builders") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) + } header: { + Label("Loop APNS Configuration", systemImage: "bell") } if let errorMessage = viewModel.loopAPNSErrorMessage, !errorMessage.isEmpty { Section { Text(errorMessage) - .foregroundColor(.red) + .foregroundStyle(.red) .font(.caption) } } - Section(header: Text("Debug / Info")) { + Section { Text("Device Token: \(Storage.shared.deviceToken.value)") Text("Bundle ID: \(Storage.shared.bundleId.value)") @@ -262,26 +280,28 @@ struct RemoteSettingsView: View { Text("Current TOTP Code:") Text(otpCode) .font(.system(.body, design: .monospaced)) - .foregroundColor(.green) + .foregroundStyle(.green) .padding(.vertical, 2) .padding(.horizontal, 6) .background(Color.green.opacity(0.1)) .cornerRadius(4) Text("(" + (otpTimeRemaining.map { "\($0)s left" } ?? "-") + ")") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } else { Text("TOTP Code: Invalid QR code URL") - .foregroundColor(.red) + .foregroundStyle(.red) } if Storage.shared.bolusIncrementDetected.value { Text("Bolus Increment: \(Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), specifier: "%.3f") U") } + } header: { + Label("Debug / Info", systemImage: "ladybug") } if viewModel.areTeamIdsDifferent { - Section(header: Text("Return Notification Settings"), footer: Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.").font(.caption)) { + Section { HStack { Text("Return APNS Key ID") TogglableSecureInput( @@ -300,6 +320,11 @@ struct RemoteSettingsView: View { ) .frame(minHeight: 110) } + } header: { + Label("Return Notification Settings", systemImage: "bell.badge") + } footer: { + Text("Because LoopFollow and the target app were built with different Team IDs, you must provide the APNS credentials for LoopFollow below.") + .font(.caption) } } } @@ -391,13 +416,13 @@ struct RemoteSettingsView: View { Spacer() if viewModel.remoteType == type { Image(systemName: "checkmark") - .foregroundColor(.accentColor) + .foregroundStyle(Color.accentColor) } } } // If isEnabled is false, user can see the row but not tap it. .disabled(!isEnabled) - .foregroundColor(isEnabled ? .primary : .gray) + .foregroundStyle(isEnabled ? Color.primary : Color.gray) } // MARK: - Validation Error Handler @@ -409,7 +434,7 @@ struct RemoteSettingsView: View { } private var guardrailsSection: some View { - Section(header: Text("Guardrails")) { + Section { HStack { Text("Max Bolus") Spacer() @@ -426,7 +451,7 @@ struct RemoteSettingsView: View { ) .frame(width: 100) Text("U") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack { @@ -445,7 +470,7 @@ struct RemoteSettingsView: View { ) .frame(width: 100) Text("g") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if device.value == "Trio" { @@ -465,7 +490,7 @@ struct RemoteSettingsView: View { ) .frame(width: 100) Text("g") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } HStack { @@ -484,9 +509,13 @@ struct RemoteSettingsView: View { ) .frame(width: 100) Text("g") - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } } + } header: { + Label("Guardrails", systemImage: "shield") + } footer: { + Text("Guardrails set maximum limits for remote commands to help prevent accidental overdoses. These limits apply to boluses and carbs sent via remote commands.") } } } diff --git a/LoopFollow/Resources/Assets.xcassets/AppIconDisplay.imageset/AppIconDisplay.png b/LoopFollow/Resources/Assets.xcassets/AppIconDisplay.imageset/AppIconDisplay.png new file mode 100644 index 000000000..8c1c15231 Binary files /dev/null and b/LoopFollow/Resources/Assets.xcassets/AppIconDisplay.imageset/AppIconDisplay.png differ diff --git a/LoopFollow/Resources/Assets.xcassets/AppIconDisplay.imageset/Contents.json b/LoopFollow/Resources/Assets.xcassets/AppIconDisplay.imageset/Contents.json new file mode 100644 index 000000000..5aa3b14e7 --- /dev/null +++ b/LoopFollow/Resources/Assets.xcassets/AppIconDisplay.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "AppIconDisplay.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopFollow/Settings/AboutView.swift b/LoopFollow/Settings/AboutView.swift new file mode 100644 index 000000000..85408cfd3 --- /dev/null +++ b/LoopFollow/Settings/AboutView.swift @@ -0,0 +1,102 @@ +// LoopFollow +// AboutView.swift + +import SwiftUI + +struct AboutView: View { + @Environment(\.dismiss) private var dismiss + + @State private var latestVersion: String? + @State private var versionTint: Color = .secondary + + var body: some View { + NavigationStack { + List { + Section { + HStack { + Spacer() + VStack(spacing: 12) { + Image("AppIconDisplay") + .resizable() + .frame(width: 80, height: 80) + .cornerRadius(16) + + Text("LoopFollow") + .font(.title2) + .fontWeight(.semibold) + + Text("Monitor blood glucose remotely") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 20) + } + + buildInfoSection + } + .navigationTitle("About") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + } + } + } + .task { await refreshVersionInfo() } + } + } + + @ViewBuilder + private var buildInfoSection: some View { + let build = BuildDetails.default + let ver = AppVersionManager().version() + + Section { + keyValue("Version", ver, tint: versionTint) + keyValue("Latest Version", latestVersion ?? "Fetching...") + + if !(build.isMacApp() || build.isSimulatorBuild()) { + keyValue(build.expirationHeaderString, + dateTimeUtils.formattedDate(from: build.calculateExpirationDate())) + } + keyValue("Built", + dateTimeUtils.formattedDate(from: build.buildDate())) + keyValue("Branch", build.branchAndSha) + } header: { + Label("Build Information", systemImage: "hammer") + } + } + + private func keyValue(_ key: String, + _ value: String, + tint: Color = .secondary) -> some View + { + HStack { + Text(key) + Spacer() + Text(value) + .foregroundStyle(tint) + } + } + + private func refreshVersionInfo() async { + let mgr = AppVersionManager() + let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync() + latestVersion = latest ?? "Unknown" + + let current = mgr.version() + versionTint = blacklisted ? .red + : newer ? .orange + : latest == current ? .green + : .secondary + } +} + +#Preview { + AboutView() +} diff --git a/LoopFollow/Settings/AdvancedSettingsView.swift b/LoopFollow/Settings/AdvancedSettingsView.swift index 1873aabf5..ec8003dac 100644 --- a/LoopFollow/Settings/AdvancedSettingsView.swift +++ b/LoopFollow/Settings/AdvancedSettingsView.swift @@ -7,24 +7,30 @@ struct AdvancedSettingsView: View { @ObservedObject var viewModel: AdvancedSettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("Advanced Settings")) { - Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) - Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) - Toggle("Graph Basal", isOn: $viewModel.graphBasal) - Toggle("Graph Bolus", isOn: $viewModel.graphBolus) - Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) - Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments) + Form { + Section { + Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) + Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) + Toggle("Graph Basal", isOn: $viewModel.graphBasal) + Toggle("Graph Bolus", isOn: $viewModel.graphBolus) + Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) + Toggle("Graph Other Treatments", isOn: $viewModel.graphOtherTreatments) - Stepper(value: $viewModel.bgUpdateDelay, in: 1 ... 30, step: 1) { - Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") - } + Stepper(value: $viewModel.bgUpdateDelay, in: 1 ... 30, step: 1) { + Text("BG Update Delay (Sec): \(viewModel.bgUpdateDelay)") } + } header: { + Label("Advanced Settings", systemImage: "gearshape.2") + } footer: { + Text("BG Update Delay adds a pause before fetching new readings to allow your CGM time to upload data.") + } - Section(header: Text("Logging Options")) { - Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel) - } + Section { + Toggle("Debug Log Level", isOn: $viewModel.debugLogLevel) + } header: { + Label("Logging Options", systemImage: "doc.text.magnifyingglass") + } footer: { + Text("Enable Debug Log Level for detailed logging when troubleshooting issues. This may increase storage usage.") } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) diff --git a/LoopFollow/Settings/CalendarSettingsView.swift b/LoopFollow/Settings/CalendarSettingsView.swift index 704ef5e2f..ac217f0c0 100644 --- a/LoopFollow/Settings/CalendarSettingsView.swift +++ b/LoopFollow/Settings/CalendarSettingsView.swift @@ -20,62 +20,66 @@ struct CalendarSettingsView: View { // MARK: Body var body: some View { - NavigationView { - Form { - // ------------- Calendar write ------------- - Section { - Toggle("Save BG to Calendar", - isOn: $writeCalendarEvent.value) - .disabled(accessDenied) // prevent use when no access - } footer: { - Text(""" - Add the Apple-Calendar complication to your watch or CarPlay \ - to see BG readings. Create a separate calendar (e.g. “Follow”) \ - — this view will **delete** events on the same calendar each time \ - it writes new readings. - """) - } + Form { + // ------------- Calendar write ------------- + Section { + Toggle("Save BG to Calendar", + isOn: $writeCalendarEvent.value) + .disabled(accessDenied) // prevent use when no access + } header: { + Label("Calendar Write", systemImage: "calendar.badge.plus") + } footer: { + Text(""" + Add the Apple-Calendar complication to your watch or CarPlay \ + to see BG readings. Create a separate calendar (e.g. "Follow") \ + — this view will **delete** events on the same calendar each time \ + it writes new readings. + """) + } - // ------------- Access / calendar picker ------------- - if accessDenied { - Text("Calendar access denied") - .foregroundColor(.red) - } else { - if !calendars.isEmpty { - Picker("Calendar", - selection: $calendarIdentifier.value) - { - ForEach(calendars, id: \.calendarIdentifier) { cal in - Text(cal.title).tag(cal.calendarIdentifier) - } + // ------------- Access / calendar picker ------------- + if accessDenied { + Text("Calendar access denied") + .foregroundStyle(.red) + } else { + if !calendars.isEmpty { + Picker("Calendar", + selection: $calendarIdentifier.value) + { + ForEach(calendars, id: \.calendarIdentifier) { cal in + Text(cal.title).tag(cal.calendarIdentifier) } } } + } - // ------------- Template lines ------------- - Section("Calendar Text") { - TextField("Line 1", text: $watchLine1.value) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) + // ------------- Template lines ------------- + Section { + TextField("Line 1", text: $watchLine1.value) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) - TextField("Line 2", text: $watchLine2.value) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - } + TextField("Line 2", text: $watchLine2.value) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + } header: { + Label("Calendar Text", systemImage: "doc.text") + } - // ------------- Variable cheat-sheet ------------- - Section("Available Variables") { - ForEach(variableDescriptions, id: \.self) { desc in - Text(desc) - } + // ------------- Variable cheat-sheet ------------- + Section { + ForEach(variableDescriptions, id: \.self) { desc in + Text(desc) } - } - .task { // runs once on appear - await requestCalendarAccessAndLoad() + } header: { + Label("Available Variables", systemImage: "list.bullet") } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Calendar", displayMode: .inline) + .task { // runs once on appear + await requestCalendarAccessAndLoad() + } } // MARK: - Helpers diff --git a/LoopFollow/Settings/ContactSettingsView.swift b/LoopFollow/Settings/ContactSettingsView.swift index 018a49a9f..c06677ecd 100644 --- a/LoopFollow/Settings/ContactSettingsView.swift +++ b/LoopFollow/Settings/ContactSettingsView.swift @@ -12,75 +12,79 @@ struct ContactSettingsView: View { @State private var alertMessage: String = "" var body: some View { - NavigationView { - Form { - Section(header: Text("Contact Integration")) { - Text("Add the contact named '\(viewModel.contactName)' to your watch face to show the current BG value in real time. Make sure to give the app full access to Contacts when prompted.") - .font(.footnote) - .foregroundColor(.secondary) - .padding(.vertical, 4) + Form { + Section { + Text("Add the contact named '\(viewModel.contactName)' to your watch face to show the current BG value in real time. Make sure to give the app full access to Contacts when prompted.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.vertical, 4) - Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled) - .toggleStyle(SwitchToggleStyle()) - .onChange(of: viewModel.contactEnabled) { isEnabled in - if isEnabled { - requestContactAccess() - } + Toggle("Enable Contact BG Updates", isOn: $viewModel.contactEnabled) + .toggleStyle(.switch) + .onChange(of: viewModel.contactEnabled) { isEnabled in + if isEnabled { + requestContactAccess() } - } + } + } header: { + Label("Contact Integration", systemImage: "person.crop.circle") + } - if viewModel.contactEnabled { - Section(header: Text("Color Options")) { - Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.") - .font(.footnote) - .foregroundColor(.secondary) - .padding(.vertical, 4) + if viewModel.contactEnabled { + Section { + Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.vertical, 4) - Picker("Select Background Color", selection: $viewModel.contactBackgroundColor) { - ForEach(ContactColorOption.allCases, id: \.rawValue) { option in - Text(option.rawValue.capitalized).tag(option.rawValue) - } + Picker("Select Background Color", selection: $viewModel.contactBackgroundColor) { + ForEach(ContactColorOption.allCases, id: \.rawValue) { option in + Text(option.rawValue.capitalized).tag(option.rawValue) } + } - Picker("Select Text Color", selection: $viewModel.contactTextColor) { - ForEach(ContactColorOption.allCases, id: \.rawValue) { option in - Text(option.rawValue.capitalized).tag(option.rawValue) - } + Picker("Select Text Color", selection: $viewModel.contactTextColor) { + ForEach(ContactColorOption.allCases, id: \.rawValue) { option in + Text(option.rawValue.capitalized).tag(option.rawValue) } } + } header: { + Label("Color Options", systemImage: "paintpalette") + } - Section(header: Text("Additional Information")) { - Text("To see your trend or delta, include one in the original '\(viewModel.contactName)' contact, or create separate contacts ending in '- Trend' and '- Delta' for up to three contacts on your watch face.") - .font(.footnote) - .foregroundColor(.secondary) - .padding(.vertical, 4) + Section { + Text("To see your trend or delta, include one in the original '\(viewModel.contactName)' contact, or create separate contacts ending in '- Trend' and '- Delta' for up to three contacts on your watch face.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.vertical, 4) - Text("Show Trend") - .font(.subheadline) - Picker("Show Trend", selection: $viewModel.contactTrend) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Text("Show Trend") + .font(.subheadline) + Picker("Show Trend", selection: $viewModel.contactTrend) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) + } + .pickerStyle(.segmented) - Text("Show Delta") - .font(.subheadline) - Picker("Show Delta", selection: $viewModel.contactDelta) { - ForEach(ContactIncludeOption.allCases, id: \.self) { option in - Text(option.rawValue).tag(option) - } + Text("Show Delta") + .font(.subheadline) + Picker("Show Delta", selection: $viewModel.contactDelta) { + ForEach(ContactIncludeOption.allCases, id: \.self) { option in + Text(option.rawValue).tag(option) } - .pickerStyle(SegmentedPickerStyle()) } + .pickerStyle(.segmented) + } header: { + Label("Additional Information", systemImage: "info.circle") } } - .alert(isPresented: $showAlert) { - Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) - } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Contact", displayMode: .inline) + .alert(isPresented: $showAlert) { + Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) + } } private func requestContactAccess() { diff --git a/LoopFollow/Settings/DexcomSettingsView.swift b/LoopFollow/Settings/DexcomSettingsView.swift index c93b31855..610dd9291 100644 --- a/LoopFollow/Settings/DexcomSettingsView.swift +++ b/LoopFollow/Settings/DexcomSettingsView.swift @@ -7,49 +7,53 @@ struct DexcomSettingsView: View { @ObservedObject var viewModel: DexcomSettingsViewModel var body: some View { - NavigationView { - Form { - Section(header: Text("Dexcom Settings")) { - HStack { - Text("User Name") - TextField("Enter User Name", text: $viewModel.userName) - .autocapitalization(.none) - .disableAutocorrection(true) - .multilineTextAlignment(.trailing) - } - - HStack { - Text("Password") - TogglableSecureInput( - placeholder: "Enter Password", - text: $viewModel.password, - style: .singleLine - ) - } + Form { + Section { + HStack { + Text("User Name") + TextField("Enter User Name", text: $viewModel.userName) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } - Picker("Server", selection: $viewModel.server) { - Text("US").tag("US") - Text("NON-US").tag("NON-US") - } - .pickerStyle(SegmentedPickerStyle()) + HStack { + Text("Password") + TogglableSecureInput( + placeholder: "Enter Password", + text: $viewModel.password, + style: .singleLine + ) } - importSection + Picker("Server", selection: $viewModel.server) { + Text("US").tag("US") + Text("NON-US").tag("NON-US") + } + .pickerStyle(.segmented) + } header: { + Label("Dexcom Settings", systemImage: "drop.fill") + } footer: { + Text("Enter your Dexcom Share credentials. Select 'US' for accounts created in the United States, otherwise select 'NON-US'.") } + + importSection } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Dexcom Settings", displayMode: .inline) } private var importSection: some View { - Section(header: Text("Import Settings")) { + Section { NavigationLink(destination: ImportExportSettingsView()) { HStack { Image(systemName: "square.and.arrow.down") - .foregroundColor(.blue) + .foregroundStyle(.blue) Text("Import Settings from QR Code") } } + } header: { + Label("Import Settings", systemImage: "square.and.arrow.down") } } } diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index e94a9040e..d509004fc 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -4,6 +4,7 @@ import SwiftUI struct GeneralSettingsView: View { + @ObservedObject var units = Storage.shared.units @ObservedObject var colorBGText = Storage.shared.colorBGText @ObservedObject var appBadge = Storage.shared.appBadge @ObservedObject var appearanceMode = Storage.shared.appearanceMode @@ -28,98 +29,117 @@ struct GeneralSettingsView: View { @ObservedObject var speakHighBGLimit = Storage.shared.speakHighBGLimit var body: some View { - NavigationView { - Form { - Section("App Settings") { - Toggle("Display App Badge", isOn: $appBadge.value) - Toggle("Persistent Notification", isOn: $persistentNotification.value) + Form { + Section { + Picker("Units", selection: $units.value) { + Text("mg/dL").tag("mg/dL") + Text("mmol/L").tag("mmol/L") } + .pickerStyle(.menu) + } header: { + Label("Units", systemImage: "ruler") + } - Section("Display") { - Picker("Appearance", selection: $appearanceMode.value) { - ForEach(AppearanceMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } + Section { + Toggle("Display App Badge", isOn: $appBadge.value) + Toggle("Persistent Notification", isOn: $persistentNotification.value) + } header: { + Label("App Settings", systemImage: "gear") + } footer: { + Text("App Badge shows your current BG on the app icon. Persistent Notification keeps a notification visible for quick access.") + } + + Section { + Picker("Appearance", selection: $appearanceMode.value) { + ForEach(AppearanceMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) } - Toggle("Display Stats", isOn: $showStats.value) - Toggle("Use IFCC A1C", isOn: $useIFCC.value) - Toggle("Display Small Graph", isOn: $showSmallGraph.value) - Toggle("Color BG Text", isOn: $colorBGText.value) - Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) - Toggle("Show Display Name", isOn: $showDisplayName.value) - Toggle("Snoozer emoji", isOn: $snoozerEmoji.value) - Toggle("Force portrait mode", isOn: $forcePortraitMode.value) - .onChange(of: forcePortraitMode.value) { _ in - if #available(iOS 16.0, *) { - let window = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first - - window?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() - } - } } + Toggle("Display Stats", isOn: $showStats.value) + Toggle("Use IFCC A1C", isOn: $useIFCC.value) + Toggle("Display Small Graph", isOn: $showSmallGraph.value) + Toggle("Color BG Text", isOn: $colorBGText.value) + Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) + Toggle("Show Display Name", isOn: $showDisplayName.value) + Toggle("Snoozer emoji", isOn: $snoozerEmoji.value) + Toggle("Force portrait mode", isOn: $forcePortraitMode.value) + .onChange(of: forcePortraitMode.value) { _ in + if #available(iOS 16.0, *) { + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first - Section("Speak BG") { - Toggle("Speak BG", isOn: $speakBG.value.animation()) - - if speakBG.value { - Picker("Language", selection: $speakLanguage.value) { - Text("English").tag("en") - Text("Italian").tag("it") - Text("Slovak").tag("sk") - Text("Swedish").tag("sv") + window?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() } + } + } header: { + Label("Display", systemImage: "display") + } - Toggle("Always", isOn: $speakBGAlways.value.animation()) + Section { + Toggle("Speak BG", isOn: $speakBG.value.animation()) - if !speakBGAlways.value { - Toggle("Low", isOn: $speakLowBG.value.animation()) - .onChange(of: speakLowBG.value) { newValue in - if newValue { - speakProactiveLowBG.value = false - } - } + if speakBG.value { + Picker("Language", selection: $speakLanguage.value) { + Text("English").tag("en") + Text("Italian").tag("it") + Text("Slovak").tag("sk") + Text("Swedish").tag("sv") + } - Toggle("Proactive Low", isOn: $speakProactiveLowBG.value.animation()) - .onChange(of: speakProactiveLowBG.value) { newValue in - if newValue { - speakLowBG.value = false - } - } + Toggle("Always", isOn: $speakBGAlways.value.animation()) - if speakLowBG.value || speakProactiveLowBG.value { - BGPicker( - title: "Low BG Limit", - range: 40 ... 108, - value: $speakLowBGLimit.value - ) + if !speakBGAlways.value { + Toggle("Low", isOn: $speakLowBG.value.animation()) + .onChange(of: speakLowBG.value) { newValue in + if newValue { + speakProactiveLowBG.value = false + } } - if speakProactiveLowBG.value { - BGPicker( - title: "Fast Drop Delta", - range: 3 ... 20, - value: $speakFastDropDelta.value - ) + Toggle("Proactive Low", isOn: $speakProactiveLowBG.value.animation()) + .onChange(of: speakProactiveLowBG.value) { newValue in + if newValue { + speakLowBG.value = false + } } - Toggle("High", isOn: $speakHighBG.value.animation()) + if speakLowBG.value || speakProactiveLowBG.value { + BGPicker( + title: "Low BG Limit", + range: 40 ... 108, + value: $speakLowBGLimit.value + ) + } - if speakHighBG.value { - BGPicker( - title: "High BG Limit", - range: 140 ... 300, - value: $speakHighBGLimit.value - ) - } + if speakProactiveLowBG.value { + BGPicker( + title: "Fast Drop Delta", + range: 3 ... 20, + value: $speakFastDropDelta.value + ) + } + + Toggle("High", isOn: $speakHighBG.value.animation()) + + if speakHighBG.value { + BGPicker( + title: "High BG Limit", + range: 140 ... 300, + value: $speakHighBGLimit.value + ) } } } + } header: { + Label("Speak BG", systemImage: "speaker.wave.2") + } footer: { + Text("Speak BG reads your blood glucose aloud. Use 'Always' to hear every reading, or set specific thresholds for low and high alerts.") } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("General Settings", displayMode: .inline) + .settingsStyle(title: "General Settings") } } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 4ebc1896a..e4cb5a67a 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -25,110 +25,124 @@ struct GraphSettingsView: View { private var nightscoutEnabled: Bool { IsNightscoutEnabled() } var body: some View { - NavigationView { - Form { - // ── Graph Display ──────────────────────────────────────────── - Section("Graph Display") { - Toggle("Display Dots", isOn: $showDots.value) - .onChange(of: showDots.value) { _ in markDirty() } + Form { + // ── Graph Display ──────────────────────────────────────────── + Section { + Toggle("Display Dots", isOn: $showDots.value) + .onChange(of: showDots.value) { _ in markDirty() } - Toggle("Display Lines", isOn: $showLines.value) - .onChange(of: showLines.value) { _ in markDirty() } + Toggle("Display Lines", isOn: $showLines.value) + .onChange(of: showLines.value) { _ in markDirty() } - if nightscoutEnabled { - Toggle("Show DIA Lines", isOn: $showDIALines.value) - .onChange(of: showDIALines.value) { _ in markDirty() } - - Toggle("Show −30 min Line", isOn: $show30MinLine.value) - .onChange(of: show30MinLine.value) { _ in markDirty() } + if nightscoutEnabled { + Toggle("Show DIA Lines", isOn: $showDIALines.value) + .onChange(of: showDIALines.value) { _ in markDirty() } - Toggle("Show −90 min Line", isOn: $show90MinLine.value) - .onChange(of: show90MinLine.value) { _ in markDirty() } - } + Toggle("Show −30 min Line", isOn: $show30MinLine.value) + .onChange(of: show30MinLine.value) { _ in markDirty() } - Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) - .onChange(of: showMidnightLines.value) { _ in markDirty() } + Toggle("Show −90 min Line", isOn: $show90MinLine.value) + .onChange(of: show90MinLine.value) { _ in markDirty() } } - // ── Treatments ─────────────────────────────────────────────── - if nightscoutEnabled { - Section("Treatments") { - Toggle("Show Carb/Bolus Values", isOn: $showValues.value) - Toggle("Show Carb Absorption", isOn: $showAbsorption.value) - Toggle("Treatments on Small Graph", - isOn: $smallGraphTreatments.value) - } + Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) + .onChange(of: showMidnightLines.value) { _ in markDirty() } + } header: { + Label("Graph Display", systemImage: "chart.line.uptrend.xyaxis") + } + + // ── Treatments ─────────────────────────────────────────────── + if nightscoutEnabled { + Section { + Toggle("Show Carb/Bolus Values", isOn: $showValues.value) + Toggle("Show Carb Absorption", isOn: $showAbsorption.value) + Toggle("Treatments on Small Graph", + isOn: $smallGraphTreatments.value) + } header: { + Label("Treatments", systemImage: "pills") } + } - // ── Small Graph ────────────────────────────────────────────── - Section("Small Graph") { + // ── Small Graph ────────────────────────────────────────────── + Section { + SettingsStepperRow( + title: "Height", + range: 40 ... 80, + step: 5, + value: $smallGraphHeight.value, + format: { "\(Int($0)) pt" } + ) + .onChange(of: smallGraphHeight.value) { _ in markDirty() } + } header: { + Label("Small Graph", systemImage: "chart.bar.xaxis") + } + + // ── Prediction ─────────────────────────────────────────────── + if nightscoutEnabled { + Section { SettingsStepperRow( - title: "Height", - range: 40 ... 80, - step: 5, - value: $smallGraphHeight.value, - format: { "\(Int($0)) pt" } + title: "Hours of Prediction", + range: 0 ... 6, + step: 0.25, + value: $predictionToLoad.value, + format: { "\($0.localized(maxFractionDigits: 2)) h" } ) - .onChange(of: smallGraphHeight.value) { _ in markDirty() } + } header: { + Label("Prediction", systemImage: "waveform.path.ecg") } + } - // ── Prediction ─────────────────────────────────────────────── - if nightscoutEnabled { - Section("Prediction") { - SettingsStepperRow( - title: "Hours of Prediction", - range: 0 ... 6, - step: 0.25, - value: $predictionToLoad.value, - format: { "\($0.localized(maxFractionDigits: 2)) h" } - ) - } - } + // ── Basal / BG scale ───────────────────────────────────────── + if nightscoutEnabled { + Section { + SettingsStepperRow( + title: "Min Basal", + range: 0.5 ... 20, + step: 0.5, + value: $minBasalScale.value, + format: { "\($0.localized(maxFractionDigits: 1)) U/h" } + ) - // ── Basal / BG scale ───────────────────────────────────────── - if nightscoutEnabled { - Section("Basal / BG Scale") { - SettingsStepperRow( - title: "Min Basal", - range: 0.5 ... 20, - step: 0.5, - value: $minBasalScale.value, - format: { "\($0.localized(maxFractionDigits: 1)) U/h" } - ) - - BGPicker( - title: "Min BG Scale", - range: 40 ... 400, - value: $minBGScale.value - ) - .onChange(of: minBGScale.value) { _ in markDirty() } - } + BGPicker( + title: "Min BG Scale", + range: 40 ... 400, + value: $minBGScale.value + ) + .onChange(of: minBGScale.value) { _ in markDirty() } + } header: { + Label("Basal / BG Scale", systemImage: "slider.horizontal.3") } + } - // ── Target lines ───────────────────────────────────────────── - Section("Target Lines") { - BGPicker(title: "Low BG Line", - range: 40 ... 120, - value: $lowLine.value) - .onChange(of: lowLine.value) { _ in markDirty() } - - BGPicker(title: "High BG Line", - range: 120 ... 400, - value: $highLine.value) - .onChange(of: highLine.value) { _ in markDirty() } - } + // ── Target lines ───────────────────────────────────────────── + Section { + BGPicker(title: "Low BG Line", + range: 40 ... 120, + value: $lowLine.value) + .onChange(of: lowLine.value) { _ in markDirty() } + + BGPicker(title: "High BG Line", + range: 120 ... 400, + value: $highLine.value) + .onChange(of: highLine.value) { _ in markDirty() } + } header: { + Label("Target Lines", systemImage: "target") + } footer: { + Text("Target lines show your desired blood glucose range on the graph. Values below the low line or above the high line indicate out-of-range readings.") + } - // ── History window ─────────────────────────────────────────── - if nightscoutEnabled { - Section("History") { - SettingsStepperRow( - title: "Show Days Back", - range: 1 ... 4, - step: 1, - value: $downloadDays.value, - format: { "\(Int($0)) d" } - ) - } + // ── History window ─────────────────────────────────────────── + if nightscoutEnabled { + Section { + SettingsStepperRow( + title: "Show Days Back", + range: 1 ... 4, + step: 1, + value: $downloadDays.value, + format: { "\(Int($0)) d" } + ) + } header: { + Label("History", systemImage: "clock.arrow.circlepath") } } } diff --git a/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift index d320c9b82..1bce4035f 100644 --- a/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift +++ b/LoopFollow/Settings/ImportExport/ImportExportSettingsView.swift @@ -9,67 +9,68 @@ struct ImportExportSettingsView: View { @StateObject private var viewModel = ImportExportSettingsViewModel() var body: some View { - NavigationView { - List { - // MARK: - Import Section + List { + // MARK: - Import Section - Section("Import Settings") { - Button(action: { - viewModel.isShowingQRCodeScanner = true - }) { - HStack { - Image(systemName: "qrcode.viewfinder") - .foregroundColor(.blue) - Text("Scan QR Code to Import Settings") - } + Section { + Button(action: { + viewModel.isShowingQRCodeScanner = true + }) { + HStack { + Image(systemName: "qrcode.viewfinder") + .foregroundStyle(.blue) + Text("Scan QR Code to Import Settings") } - .buttonStyle(.plain) } + .buttonStyle(.plain) + } header: { + Label("Import Settings", systemImage: "square.and.arrow.down") + } - // MARK: - Export Section + // MARK: - Export Section - Section("Export Settings To QR Code") { - ForEach(ImportExportSettingsViewModel.ExportType.allCases, id: \.self) { exportType in - Button(action: { - if exportType == .alarms { - viewModel.showAlarmSelection() - } else { - viewModel.exportType = exportType - if let qrString = viewModel.generateQRCodeForExport() { - viewModel.qrCodeString = qrString - viewModel.isShowingQRCodeDisplay = true - } - } - }) { - HStack { - Image(systemName: exportType.icon) - .foregroundColor(.blue) - Text("Export \(exportType.rawValue)") - Spacer() - Image(systemName: exportType == .alarms ? "list.bullet" : "qrcode") - .foregroundColor(.secondary) + Section { + ForEach(ImportExportSettingsViewModel.ExportType.allCases, id: \.self) { exportType in + Button(action: { + if exportType == .alarms { + viewModel.showAlarmSelection() + } else { + viewModel.exportType = exportType + if let qrString = viewModel.generateQRCodeForExport() { + viewModel.qrCodeString = qrString + viewModel.isShowingQRCodeDisplay = true } } - .buttonStyle(.plain) + }) { + HStack { + Image(systemName: exportType.icon) + .foregroundStyle(.blue) + Text("Export \(exportType.rawValue)") + Spacer() + Image(systemName: exportType == .alarms ? "list.bullet" : "qrcode") + .foregroundStyle(.secondary) + } } + .buttonStyle(.plain) } + } header: { + Label("Export Settings To QR Code", systemImage: "qrcode") + } - // MARK: - Status Message + // MARK: - Status Message - if !viewModel.qrCodeErrorMessage.isEmpty { - Section { - let isSuccess = viewModel.qrCodeErrorMessage.contains("successfully") || viewModel.qrCodeErrorMessage.contains("Successfully imported") - let displayText = isSuccess ? "✅ \(viewModel.qrCodeErrorMessage)" : viewModel.qrCodeErrorMessage + if !viewModel.qrCodeErrorMessage.isEmpty { + Section { + let isSuccess = viewModel.qrCodeErrorMessage.contains("successfully") || viewModel.qrCodeErrorMessage.contains("Successfully imported") + let displayText = isSuccess ? "✅ \(viewModel.qrCodeErrorMessage)" : viewModel.qrCodeErrorMessage - Text(displayText) - .foregroundColor(isSuccess ? .green : .red) - .font(.caption) - } + Text(displayText) + .foregroundStyle(isSuccess ? .green : .red) + .font(.caption) } } - .navigationTitle("Import/Export Settings") - .navigationBarTitleDisplayMode(.inline) } + .settingsStyle(title: "Import/Export Settings") .sheet(isPresented: $viewModel.isShowingQRCodeScanner) { SimpleQRCodeScannerView { result in viewModel.handleQRCodeScanResult(result) @@ -87,12 +88,12 @@ struct ImportExportSettingsView: View { Text("Scan this QR code with another LoopFollow app to import \(viewModel.exportType.rawValue.lowercased())") .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 20) } else { Text("Failed to generate QR code") - .foregroundColor(.red) + .foregroundStyle(.red) .padding() } } @@ -133,7 +134,7 @@ struct ImportConfirmationView: View { VStack(spacing: 8) { Image(systemName: "square.and.arrow.down") .font(.system(size: 50)) - .foregroundColor(.blue) + .foregroundStyle(.blue) Text("Import Settings") .font(.title2) @@ -141,7 +142,7 @@ struct ImportConfirmationView: View { Text("Review the settings that will be imported") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(.top, 20) @@ -198,15 +199,15 @@ struct ImportConfirmationView: View { VStack(spacing: 8) { HStack { Image(systemName: "exclamationmark.triangle") - .foregroundColor(.orange) + .foregroundStyle(.orange) Text("Warning") .fontWeight(.semibold) - .foregroundColor(.orange) + .foregroundStyle(.orange) } Text("This will overwrite your current settings") .font(.subheadline) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(.horizontal) @@ -223,7 +224,7 @@ struct ImportConfirmationView: View { Text("Import Settings") } .font(.headline) - .foregroundColor(.white) + .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding() .background(Color.blue) @@ -238,7 +239,7 @@ struct ImportConfirmationView: View { Text("Cancel") } .font(.headline) - .foregroundColor(.primary) + .foregroundStyle(.primary) .frame(maxWidth: .infinity) .padding() .background(Color(.systemGray6)) @@ -263,7 +264,7 @@ struct SettingRowView: View { HStack(spacing: 12) { Image(systemName: icon) .font(.title2) - .foregroundColor(color) + .foregroundStyle(color) .frame(width: 30) VStack(alignment: .leading, spacing: 2) { @@ -273,7 +274,7 @@ struct SettingRowView: View { Text(value) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) .lineLimit(2) } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 5343bf8ce..9b0fe13ea 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -5,17 +5,20 @@ import SwiftUI import UIKit struct SettingsMenuView: View { + // MARK: - Init parameters + + /// When true, shows a close button for modal dismissal + var isModal: Bool = false + + // MARK: - Environment + + @Environment(\.dismiss) private var dismiss + // MARK: - Observed Objects @ObservedObject private var nightscoutURL = Storage.shared.url @ObservedObject private var settingsPath = Observable.shared.settingsPath - // MARK: – Local state - - @State private var latestVersion: String? - @State private var versionTint: Color = .secondary - @State private var showingTabCustomization = false - // MARK: – Observed objects @ObservedObject private var url = Storage.shared.url @@ -25,71 +28,62 @@ struct SettingsMenuView: View { var body: some View { NavigationStack(path: $settingsPath.value) { List { - // ───────── Data settings ───────── - dataSection - // ───────── App settings ───────── Section("App Settings") { - NavigationRow(title: "Background Refresh Settings", - icon: "arrow.clockwise") + NavigationRow(title: "General", + icon: "gearshape") { - settingsPath.value.append(Sheet.backgroundRefresh) + settingsPath.value.append(Sheet.general) } - NavigationRow(title: "General Settings", - icon: "gearshape") + NavigationRow(title: "Background Refresh", + icon: "arrow.clockwise") { - settingsPath.value.append(Sheet.general) + settingsPath.value.append(Sheet.backgroundRefresh) } - NavigationRow(title: "Graph Settings", + NavigationRow(title: "Graph", icon: "chart.xyaxis.line") { settingsPath.value.append(Sheet.graph) } - NavigationRow(title: "Tab Settings", + NavigationRow(title: "Tabs", icon: "rectangle.3.group") { - showingTabCustomization = true + settingsPath.value.append(Sheet.tabs) } - NavigationRow(title: "Import/Export Settings", + NavigationRow(title: "Import/Export", icon: "square.and.arrow.down") { settingsPath.value.append(Sheet.importExport) } if !nightscoutURL.value.isEmpty { - NavigationRow(title: "Information Display Settings", + NavigationRow(title: "Information Display", icon: "info.circle") { settingsPath.value.append(Sheet.infoDisplay) } - NavigationRow(title: "Remote Settings", + NavigationRow(title: "Remote", icon: "antenna.radiowaves.left.and.right") { settingsPath.value.append(Sheet.remote) } } - } - // ───────── Alarms ───────── - Section { NavigationRow(title: "Alarms", - icon: "bell") - { - settingsPath.value.append(Sheet.alarmsList) - } - - NavigationRow(title: "Alarm Settings", icon: "bell.badge") { settingsPath.value.append(Sheet.alarmSettings) } } + // ───────── Data settings ───────── + dataSection + // ───────── Integrations ───────── Section("Integrations") { NavigationRow(title: "Calendar", @@ -106,8 +100,8 @@ struct SettingsMenuView: View { } // ───────── Advanced / Logs ───────── - Section("Advanced Settings") { - NavigationRow(title: "Advanced Settings", + Section("Advanced") { + NavigationRow(title: "Advanced", icon: "exclamationmark.shield") { settingsPath.value.append(Sheet.advanced) @@ -125,30 +119,28 @@ struct SettingsMenuView: View { icon: "square.and.arrow.up", action: shareLogs) } - - // ───────── Community ───────── - Section("Community") { - LinkRow(title: "LoopFollow Facebook Group", - icon: "person.2.fill", - url: URL(string: "https://www.facebook.com/groups/loopfollowlnl")!) - } - - // ───────── Build info ───────── - buildInfoSection } .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: Sheet.self) { $0.destination } - .sheet(isPresented: $showingTabCustomization) { - TabCustomizationModal( - isPresented: $showingTabCustomization, - onApply: { - // Dismiss any presented view controller and go to home tab - handleTabReorganization() + .toolbar { + if isModal { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } else { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + } } - ) + } } } - .task { await refreshVersionInfo() } } // MARK: – Section builders @@ -156,23 +148,13 @@ struct SettingsMenuView: View { @ViewBuilder private var dataSection: some View { Section("Data Settings") { - Picker("Units", - selection: Binding( - get: { Storage.shared.units.value }, - set: { Storage.shared.units.value = $0 } - )) { - Text("mg/dL").tag("mg/dL") - Text("mmol/L").tag("mmol/L") - } - .pickerStyle(.segmented) - - NavigationRow(title: "Nightscout Settings", + NavigationRow(title: "Nightscout", icon: "network") { settingsPath.value.append(Sheet.nightscout) } - NavigationRow(title: "Dexcom Settings", + NavigationRow(title: "Dexcom", icon: "sensor.tag.radiowaves.forward") { settingsPath.value.append(Sheet.dexcom) @@ -180,50 +162,8 @@ struct SettingsMenuView: View { } } - @ViewBuilder - private var buildInfoSection: some View { - let build = BuildDetails.default - let ver = AppVersionManager().version() - - Section("Build Information") { - keyValue("Version", ver, tint: versionTint) - keyValue("Latest version", latestVersion ?? "Fetching…") - - if !(build.isMacApp() || build.isSimulatorBuild()) { - keyValue(build.expirationHeaderString, - dateTimeUtils.formattedDate(from: build.calculateExpirationDate())) - } - keyValue("Built", - dateTimeUtils.formattedDate(from: build.buildDate())) - keyValue("Branch", build.branchAndSha) - } - } - // MARK: – Helpers - private func keyValue(_ key: String, - _ value: String, - tint: Color = .secondary) -> some View - { - HStack { - Text(key) - Spacer() - Text(value).foregroundColor(tint) - } - } - - private func refreshVersionInfo() async { - let mgr = AppVersionManager() - let (latest, newer, blacklisted) = await mgr.checkForNewVersionAsync() - latestVersion = latest ?? "Unknown" - - let current = mgr.version() - versionTint = blacklisted ? .red - : newer ? .orange - : latest == current ? .green - : .secondary - } - private func shareLogs() { let files = LogManager.shared.logFilesForTodayAndYesterday() guard !files.isEmpty else { @@ -237,37 +177,6 @@ struct SettingsMenuView: View { applicationActivities: nil) UIApplication.shared.topMost?.present(avc, animated: true) } - - private func handleTabReorganization() { - // Find the root tab bar controller - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController else { return } - - // Navigate through the hierarchy to find the tab bar controller - var tabBarController: UITabBarController? - - if let tbc = rootVC as? UITabBarController { - tabBarController = tbc - } else if let nav = rootVC as? UINavigationController, - let tbc = nav.viewControllers.first as? UITabBarController - { - tabBarController = tbc - } - - guard let tabBar = tabBarController else { return } - - // Dismiss any modals first - if let presented = tabBar.presentedViewController { - presented.dismiss(animated: false) { - // After dismissal, switch to home tab - tabBar.selectedIndex = 0 - } - } else { - // No modal to dismiss, just switch to home - tabBar.selectedIndex = 0 - } - } } // MARK: – Sheet routing @@ -275,7 +184,7 @@ struct SettingsMenuView: View { private enum Sheet: Hashable, Identifiable { case nightscout, dexcom case backgroundRefresh - case general, graph + case general, graph, tabs case infoDisplay case alarmsList, alarmSettings case remote @@ -294,6 +203,7 @@ private enum Sheet: Hashable, Identifiable { case .backgroundRefresh: BackgroundRefreshSettingsView(viewModel: .init()) case .general: GeneralSettingsView() case .graph: GraphSettingsView() + case .tabs: TabSettingsView() case .infoDisplay: InfoDisplaySettingsView(viewModel: .init()) case .alarmsList: AlarmListView() case .alarmSettings: AlarmSettingsView() diff --git a/LoopFollow/Settings/TabCustomizationModal.swift b/LoopFollow/Settings/TabCustomizationModal.swift index 572b3a1e0..d1fd5e08f 100644 --- a/LoopFollow/Settings/TabCustomizationModal.swift +++ b/LoopFollow/Settings/TabCustomizationModal.swift @@ -2,10 +2,10 @@ // TabCustomizationModal.swift import SwiftUI +import UIKit -struct TabCustomizationModal: View { - @Binding var isPresented: Bool - let onApply: () -> Void +struct TabSettingsView: View { + @Environment(\.dismiss) private var dismiss // Local state for editing @State private var alarmsPosition: TabPosition @@ -18,11 +18,7 @@ struct TabCustomizationModal: View { private let originalRemotePosition: TabPosition private let originalNightscoutPosition: TabPosition - init(isPresented: Binding, onApply: @escaping () -> Void) { - _isPresented = isPresented - self.onApply = onApply - - // Initialize with current values + init() { let currentAlarms = Storage.shared.alarmsPosition.value let currentRemote = Storage.shared.remotePosition.value let currentNightscout = Storage.shared.nightscoutPosition.value @@ -37,69 +33,51 @@ struct TabCustomizationModal: View { } var body: some View { - NavigationView { - Form { - Section("Tab Positions") { - TabPositionRow( - title: "Alarms", - icon: "alarm", - position: $alarmsPosition, - otherPositions: [remotePosition, nightscoutPosition] - ) - .onChange(of: alarmsPosition) { _ in checkForChanges() } - - TabPositionRow( - title: "Remote", - icon: "antenna.radiowaves.left.and.right", - position: $remotePosition, - otherPositions: [alarmsPosition, nightscoutPosition] - ) - .onChange(of: remotePosition) { _ in checkForChanges() } - - TabPositionRow( - title: "Nightscout", - icon: "safari", - position: $nightscoutPosition, - otherPositions: [alarmsPosition, remotePosition] - ) - .onChange(of: nightscoutPosition) { _ in checkForChanges() } - } - - Section { - Text("• Tab 2 and Tab 4 can each hold one item") - .font(.caption) - .foregroundColor(.secondary) - Text("• Items in 'More Menu' appear under the last tab") - .font(.caption) - .foregroundColor(.secondary) - Text("• Hidden items are not accessible") - .font(.caption) - .foregroundColor(.secondary) - } + Form { + Section("Tab Positions") { + TabPositionRow( + title: "Alarms", + icon: "alarm", + position: $alarmsPosition, + otherPositions: [remotePosition, nightscoutPosition] + ) + .onChange(of: alarmsPosition) { _ in checkForChanges() } + + TabPositionRow( + title: "Remote", + icon: "antenna.radiowaves.left.and.right", + position: $remotePosition, + otherPositions: [alarmsPosition, nightscoutPosition] + ) + .onChange(of: remotePosition) { _ in checkForChanges() } + + TabPositionRow( + title: "Nightscout", + icon: "safari", + position: $nightscoutPosition, + otherPositions: [alarmsPosition, remotePosition] + ) + .onChange(of: nightscoutPosition) { _ in checkForChanges() } + } - if hasChanges { - Section { - Text("Changes will be applied when you tap 'Apply'") - .font(.caption) - .foregroundColor(.orange) - } - } + Section { + Text("• Tab 2 and Tab 4 can each hold one item") + .font(.caption) + .foregroundColor(.secondary) + Text("• Items in 'More Menu' appear under the last tab") + .font(.caption) + .foregroundColor(.secondary) + Text("• Hidden items are not accessible") + .font(.caption) + .foregroundColor(.secondary) } - .navigationTitle("Tab Settings") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - isPresented = false - } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button("Apply") { + if hasChanges { + Section { + Button("Apply Changes") { applyChanges() } .fontWeight(.semibold) - .disabled(!hasChanges) } } } @@ -113,17 +91,40 @@ struct TabCustomizationModal: View { } private func applyChanges() { - // Save the new positions Storage.shared.alarmsPosition.value = alarmsPosition Storage.shared.remotePosition.value = remotePosition Storage.shared.nightscoutPosition.value = nightscoutPosition - // Dismiss the modal - isPresented = false + dismiss() - // Call the completion handler after a small delay to ensure modal is dismissed + // Handle tab reorganization after dismissal DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - onApply() + handleTabReorganization() + } + } + + private func handleTabReorganization() { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController else { return } + + var tabBarController: UITabBarController? + if let tbc = rootVC as? UITabBarController { + tabBarController = tbc + } else if let nav = rootVC as? UINavigationController, + let tbc = nav.viewControllers.first as? UITabBarController + { + tabBarController = tbc + } + + guard let tabBar = tabBarController else { return } + + if let presented = tabBar.presentedViewController { + presented.dismiss(animated: false) { + tabBar.selectedIndex = 0 + } + } else { + tabBar.selectedIndex = 0 } } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9ccbaa5bf..d5e7d4c1f 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -573,8 +573,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Tab 4 - Settings or More if willHaveMoreTab { let moreVC = MoreMenuViewController() - moreVC.tabBarItem = UITabBarItem(title: "More", image: UIImage(systemName: "ellipsis"), tag: 4) - viewControllers.append(moreVC) + let moreNav = UINavigationController(rootViewController: moreVC) + moreNav.tabBarItem = UITabBarItem(title: "More", image: UIImage(systemName: "ellipsis"), tag: 4) + + viewControllers.append(moreNav) } else { let settingsVC = SettingsViewController() settingsVC.tabBarItem = UITabBarItem(title: "Settings", image: UIImage(systemName: "gear"), tag: 4) diff --git a/LoopFollow/ViewControllers/MoreMenuViewController.swift b/LoopFollow/ViewControllers/MoreMenuViewController.swift index c714d1bc0..41681175f 100644 --- a/LoopFollow/ViewControllers/MoreMenuViewController.swift +++ b/LoopFollow/ViewControllers/MoreMenuViewController.swift @@ -15,12 +15,16 @@ class MoreMenuViewController: UIViewController { let action: () -> Void } - private var menuItems: [MenuItem] = [] + struct MenuSection { + let title: String? + let items: [MenuItem] + } + + private var sections: [MenuSection] = [] override func viewDidLoad() { super.viewDidLoad() - title = "More" view.backgroundColor = .systemBackground // Apply appearance mode @@ -43,12 +47,14 @@ class MoreMenuViewController: UIViewController { .store(in: &cancellables) setupTableView() - updateMenuItems() + updateSections() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - updateMenuItems() + // Hide navigation bar on More page + navigationController?.setNavigationBarHidden(true, animated: animated) + updateSections() tableView.reloadData() Observable.shared.settingsPath.set(NavigationPath()) } @@ -80,11 +86,13 @@ class MoreMenuViewController: UIViewController { ]) } - private func updateMenuItems() { - menuItems = [] + private func updateSections() { + sections = [] + + // Main section - Settings and dynamic items + var mainItems: [MenuItem] = [] - // Always add Settings - menuItems.append(MenuItem( + mainItems.append(MenuItem( title: "Settings", icon: "gear", action: { [weak self] in @@ -92,9 +100,8 @@ class MoreMenuViewController: UIViewController { } )) - // Add items based on their positions if Storage.shared.alarmsPosition.value == .more { - menuItems.append(MenuItem( + mainItems.append(MenuItem( title: "Alarms", icon: "alarm", action: { [weak self] in @@ -104,7 +111,7 @@ class MoreMenuViewController: UIViewController { } if Storage.shared.remotePosition.value == .more { - menuItems.append(MenuItem( + mainItems.append(MenuItem( title: "Remote", icon: "antenna.radiowaves.left.and.right", action: { [weak self] in @@ -114,7 +121,7 @@ class MoreMenuViewController: UIViewController { } if Storage.shared.nightscoutPosition.value == .more { - menuItems.append(MenuItem( + mainItems.append(MenuItem( title: "Nightscout", icon: "safari", action: { [weak self] in @@ -122,104 +129,137 @@ class MoreMenuViewController: UIViewController { } )) } + + sections.append(MenuSection(title: nil, items: mainItems)) + + // Community section + sections.append(MenuSection( + title: "Community", + items: [ + MenuItem( + title: "LoopFollow Facebook Group", + icon: "person.2.fill", + action: { [weak self] in + self?.openFacebookGroup() + } + ), + ] + )) + + // About section + sections.append(MenuSection( + title: "About", + items: [ + MenuItem( + title: "About LoopFollow", + icon: "info.circle", + action: { [weak self] in + self?.openAbout() + } + ), + ] + )) } private func openSettings() { - let settingsVC = UIHostingController(rootView: SettingsMenuView()) - let navController = UINavigationController(rootViewController: settingsVC) + let settingsVC = UIHostingController(rootView: SettingsMenuView(isModal: false)) + settingsVC.title = "Settings" + settingsVC.hidesBottomBarWhenPushed = true // Apply appearance mode let style = Storage.shared.appearanceMode.value.userInterfaceStyle settingsVC.overrideUserInterfaceStyle = style - navController.overrideUserInterfaceStyle = style - - // Add a close button - settingsVC.navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(dismissModal) - ) + navigationController?.overrideUserInterfaceStyle = style - navController.modalPresentationStyle = .fullScreen - present(navController, animated: true) + // Hide UIKit nav bar - SwiftUI handles navigation + navigationController?.setNavigationBarHidden(true, animated: false) + navigationController?.pushViewController(settingsVC, animated: true) } private func openAlarms() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let alarmsVC = storyboard.instantiateViewController(withIdentifier: "AlarmViewController") - let navController = UINavigationController(rootViewController: alarmsVC) + alarmsVC.hidesBottomBarWhenPushed = true // Apply appearance mode let style = Storage.shared.appearanceMode.value.userInterfaceStyle alarmsVC.overrideUserInterfaceStyle = style - navController.overrideUserInterfaceStyle = style + navigationController?.overrideUserInterfaceStyle = style - // Add a close button - alarmsVC.navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(dismissModal) - ) - - navController.modalPresentationStyle = .fullScreen - present(navController, animated: true) + // Show UIKit nav bar for UIKit views + navigationController?.setNavigationBarHidden(false, animated: false) + navigationController?.pushViewController(alarmsVC, animated: true) } private func openRemote() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let remoteVC = storyboard.instantiateViewController(withIdentifier: "RemoteViewController") - let navController = UINavigationController(rootViewController: remoteVC) + remoteVC.hidesBottomBarWhenPushed = true // Apply appearance mode let style = Storage.shared.appearanceMode.value.userInterfaceStyle remoteVC.overrideUserInterfaceStyle = style - navController.overrideUserInterfaceStyle = style - - // Add a close button - remoteVC.navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(dismissModal) - ) + navigationController?.overrideUserInterfaceStyle = style - navController.modalPresentationStyle = .fullScreen - present(navController, animated: true) + // Show UIKit nav bar for UIKit views + navigationController?.setNavigationBarHidden(false, animated: false) + navigationController?.pushViewController(remoteVC, animated: true) } private func openNightscout() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let nightscoutVC = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController") - let navController = UINavigationController(rootViewController: nightscoutVC) + nightscoutVC.hidesBottomBarWhenPushed = true // Apply appearance mode let style = Storage.shared.appearanceMode.value.userInterfaceStyle nightscoutVC.overrideUserInterfaceStyle = style - navController.overrideUserInterfaceStyle = style + navigationController?.overrideUserInterfaceStyle = style - // Add a close button - nightscoutVC.navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .done, - target: self, - action: #selector(dismissModal) - ) + // Show UIKit nav bar for UIKit views + navigationController?.setNavigationBarHidden(false, animated: false) + navigationController?.pushViewController(nightscoutVC, animated: true) + } - navController.modalPresentationStyle = .fullScreen - present(navController, animated: true) + private func openFacebookGroup() { + if let url = URL(string: "https://www.facebook.com/groups/loopfollowlnl") { + UIApplication.shared.open(url) + } } - @objc private func dismissModal() { - dismiss(animated: true) + private func openAbout() { + let aboutView = AboutView() + let hostingController = UIHostingController(rootView: aboutView) + hostingController.title = "About" + hostingController.hidesBottomBarWhenPushed = true + + // Apply appearance mode + let style = Storage.shared.appearanceMode.value.userInterfaceStyle + hostingController.overrideUserInterfaceStyle = style + navigationController?.overrideUserInterfaceStyle = style + + // Hide UIKit nav bar - SwiftUI handles navigation + navigationController?.setNavigationBarHidden(true, animated: false) + navigationController?.pushViewController(hostingController, animated: true) } } extension MoreMenuViewController: UITableViewDataSource, UITableViewDelegate { - func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - return menuItems.count + func numberOfSections(in _: UITableView) -> Int { + return sections.count + } + + func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + return sections[section].items.count + } + + func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? { + return sections[section].title } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - let item = menuItems[indexPath.row] + let item = sections[indexPath.section].items[indexPath.row] var config = cell.defaultContentConfiguration() config.text = item.title @@ -232,6 +272,6 @@ extension MoreMenuViewController: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - menuItems[indexPath.row].action() + sections[indexPath.section].items[indexPath.row].action() } }