From 28479d6dd561176ce09af66b5b6280fbbc336658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 18 Apr 2026 20:34:43 +0200 Subject: [PATCH] Add inline documentation sheets to settings views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add info button (ℹ) to section headers in Nightscout, General, Graph, and Background Refresh settings. Tapping the button presents a sheet with contextual help sourced from the LoopFollow documentation site. Uses a single .sheet(item:) per view to avoid UIKit presentation conflicts when multiple sheets exist in the same Form. --- .../BackgroundRefreshSettingsView.swift | 64 +++++++- .../Nightscout/NightscoutSettingsView.swift | 103 +++++++++++- LoopFollow/Settings/GeneralSettingsView.swift | 146 +++++++++++++++++- LoopFollow/Settings/GraphSettingsView.swift | 80 +++++++++- 4 files changed, 384 insertions(+), 9 deletions(-) diff --git a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift index a50bc6ea9..2732a4c41 100644 --- a/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift +++ b/LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift @@ -7,6 +7,7 @@ struct BackgroundRefreshSettingsView: View { @ObservedObject var viewModel: BackgroundRefreshSettingsViewModel @State private var forceRefresh = false @State private var timer: Timer? + @State private var showInfo = false @ObservedObject var bleManager = BLEManager.shared @@ -20,6 +21,9 @@ struct BackgroundRefreshSettingsView: View { availableDevicesSection } } + .sheet(isPresented: $showInfo) { + backgroundRefreshInfoSheet + } .onAppear { startTimer() } @@ -34,7 +38,7 @@ struct BackgroundRefreshSettingsView: View { // MARK: - Subviews / Computed Properties private var refreshTypeSection: some View { - Section { + Section(header: refreshTypeSectionHeader) { Picker("Background Refresh Type", selection: $viewModel.backgroundRefreshType) { ForEach(BackgroundRefreshType.allCases, id: \.self) { type in Text(type.rawValue).tag(type) @@ -176,6 +180,64 @@ struct BackgroundRefreshSettingsView: View { } } + // MARK: - Section Header & Info Sheet + + private var refreshTypeSectionHeader: some View { + HStack(spacing: 4) { + Text("Background Refresh") + Button { + showInfo = true + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + } + + private var backgroundRefreshInfoSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("LoopFollow needs to stay active in the background to check for alarms and update glucose values. There are several methods available:") + + Text("Silent Tune") + .font(.headline) + Text("Plays a silent audio track to keep the app active. This has several drawbacks including battery drain and limited reliability — it may be interrupted by other apps.") + + Text("Bluetooth Heartbeat") + .font(.headline) + Text("Uses an external Bluetooth device to keep LoopFollow awake. This can save significantly on battery and provides more reliable background operation.") + + Text("Supported Bluetooth Devices") + .font(.headline) + Text(verbatim: """ + • Radiolink: RileyLink, OrangeLink, Emalink — heartbeat every minute + • Dexcom G5/G6/ONE/Anubis transmitter — heartbeat every ~5 minutes + • Dexcom G7/ONE+ sensor — heartbeat every ~5 minutes + + Dexcom device batteries continue to provide Bluetooth power for months after they are no longer in service with a sensor. + """) + + Text("If the person using LoopFollow is also wearing a Dexcom or radiolink, they should choose their own device.") + .font(.footnote) + .foregroundColor(.secondary) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Background Refresh") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { showInfo = false } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + private func startTimer() { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in self.forceRefresh.toggle() diff --git a/LoopFollow/Nightscout/NightscoutSettingsView.swift b/LoopFollow/Nightscout/NightscoutSettingsView.swift index 7c08e756f..6018ecd83 100644 --- a/LoopFollow/Nightscout/NightscoutSettingsView.swift +++ b/LoopFollow/Nightscout/NightscoutSettingsView.swift @@ -5,6 +5,12 @@ import SwiftUI struct NightscoutSettingsView: View { @ObservedObject var viewModel: NightscoutSettingsViewModel + @State private var activeInfoSheet: InfoSheet? + + private enum InfoSheet: Identifiable { + case url, token + var id: Self { self } + } var body: some View { NavigationView { @@ -14,6 +20,12 @@ struct NightscoutSettingsView: View { statusSection importSection } + .sheet(item: $activeInfoSheet) { sheet in + switch sheet { + case .url: urlInfoSheet + case .token: tokenInfoSheet + } + } .onDisappear { viewModel.dismiss() } @@ -22,10 +34,10 @@ struct NightscoutSettingsView: View { .navigationBarTitle("Nightscout Settings", displayMode: .inline) } - // MARK: - Subviews / Computed Properties + // MARK: - Sections private var urlSection: some View { - Section(header: Text("URL")) { + Section(header: sectionHeader("URL", sheet: .url)) { TextField("Enter URL", text: $viewModel.nightscoutURL) .textContentType(.username) .autocapitalization(.none) @@ -37,7 +49,7 @@ struct NightscoutSettingsView: View { } private var tokenSection: some View { - Section(header: Text("Token")) { + Section(header: sectionHeader("Token", sheet: .token)) { HStack { Text("Access Token") TogglableSecureInput( @@ -67,4 +79,89 @@ struct NightscoutSettingsView: View { } } } + + // MARK: - Section Header + + private func sectionHeader(_ title: String, sheet: InfoSheet) -> some View { + HStack(spacing: 4) { + Text(title) + Button { + activeInfoSheet = sheet + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + } + + // MARK: - Info Sheets + + private var urlInfoSheet: some View { + NavigationStack { + ScrollView { + Text(verbatim: """ + Enter your Nightscout site URL, for example: + https://yoursite.yourprovider.com + + You can copy your full URL (including the token) from Nightscout Admin Tools. When pasted here, LoopFollow automatically extracts both the URL and the token. + + To find your URL, open your Nightscout site in a browser and copy the address from the address bar. Remove any trailing slashes or path components — just the base URL is needed. + """) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Nightscout URL") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { activeInfoSheet = nil } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private var tokenInfoSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(""" + A token controls what LoopFollow can access on your Nightscout site. Tokens are not the same as API keys — they are created within Nightscout itself. + """) + + Text("Creating a Token") + .font(.headline) + Text(""" + 1. Open your Nightscout site in a browser + 2. Go to the hamburger menu (☰) and select Admin Tools + 3. Under "Subjects", tap "Add new Subject" + 4. Enter a name (e.g. "LoopFollow") and select a role + 5. Save, then copy the token (it looks like: loopfollow-1234567890abcdef) + """) + + Text("Which Role Do I Need?") + .font(.headline) + Text(""" + • Read — sufficient for most setups, including Loop and Trio remote control via APNS + • Read & Write (Careportal) — required only for Nightscout Remote Control (Trio 0.2.x or older) + + If your Nightscout site is publicly readable, you can leave the token empty. The status will show "OK (Read)". + """) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Access Token") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { activeInfoSheet = nil } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } } diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 93b7c8f4f..a62855d9a 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -29,15 +29,22 @@ struct GeneralSettingsView: View { @ObservedObject var speakHighBG = Storage.shared.speakHighBG @ObservedObject var speakHighBGLimit = Storage.shared.speakHighBGLimit + @State private var activeInfoSheet: InfoSheet? + + private enum InfoSheet: Identifiable { + case appSettings, display, speakBG + var id: Self { self } + } + var body: some View { NavigationView { Form { - Section("App Settings") { + Section(header: sectionHeader("App Settings", sheet: .appSettings)) { Toggle("Display App Badge", isOn: $appBadge.value) Toggle("Persistent Notification", isOn: $persistentNotification.value) } - Section("Display") { + Section(header: sectionHeader("Display", sheet: .display)) { Picker("Appearance", selection: $appearanceMode.value) { ForEach(AppearanceMode.allCases, id: \.self) { mode in Text(mode.displayName).tag(mode) @@ -75,7 +82,7 @@ struct GeneralSettingsView: View { } } - Section("Speak BG") { + Section(header: sectionHeader("Speak BG", sheet: .speakBG)) { Toggle("Speak BG", isOn: $speakBG.value.animation()) if speakBG.value { @@ -133,11 +140,144 @@ struct GeneralSettingsView: View { } } } + .sheet(item: $activeInfoSheet) { sheet in + switch sheet { + case .appSettings: appSettingsInfoSheet + case .display: displayInfoSheet + case .speakBG: speakBGInfoSheet + } + } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("General Settings", displayMode: .inline) } + // MARK: - Section Header + + private func sectionHeader(_ title: String, sheet: InfoSheet) -> some View { + HStack(spacing: 4) { + Text(title) + Button { + activeInfoSheet = sheet + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + } + + // MARK: - Info Sheets + + private var appSettingsInfoSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Display App Badge") + .font(.headline) + Text("Shows the current glucose value on the app icon. For the badge to stay current, you need to enable a Background Refresh option — otherwise it will go stale when the app is in the background.") + + Text("Persistent Notification") + .font(.headline) + Text("When enabled, glucose is reported as a notification with every update. This is typically left disabled.") + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("App Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { activeInfoSheet = nil } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private var displayInfoSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Display Stats") + .font(.headline) + Text("Shows 24-hour statistics (time in range, average BG, estimated A1C, standard deviation) on the Home screen.") + + Text("Use IFCC A1C") + .font(.headline) + Text("Displays estimated A1C in mmol/mol (IFCC) instead of the default % (NGSP/DCCT). Common in many countries outside the US.") + + Text("Display Small Graph") + .font(.headline) + Text("Shows a full history graph below the main plot. The history length is determined by the \"Days Back\" setting in Graph settings.") + + Text("Color BG Text") + .font(.headline) + Text("Uses colors to highlight glucose values — red for low, green for in range, and yellow for high — across the app.") + + Text("Keep Screen Active") + .font(.headline) + Text("Overrides your phone's auto-lock setting to keep the screen on. This works whether plugged in or not, so remember to lock the screen manually to conserve battery.") + + Text("Show Display Name") + .font(.headline) + Text("Shows the app name on the Home screen. Useful when following more than one person, to tell multiple LoopFollow instances apart.") + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Display Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { activeInfoSheet = nil } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + + private var speakBGInfoSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("When enabled, LoopFollow speaks glucose values aloud. You can choose to have it speak every reading, or only when glucose is outside a range you define.") + + Text("Always vs. Conditional") + .font(.headline) + Text("\"Always\" speaks every glucose reading. When off, you can selectively enable speaking for low and/or high values only.") + + Text("Low vs. Proactive Low") + .font(.headline) + Text(verbatim: """ + These are mutually exclusive: + • Low — speaks when glucose is at or below the Low BG Limit + • Proactive Low — speaks when glucose is below the limit OR dropping fast (exceeding the Fast Drop Delta), even if still above the limit + """) + + Text("Fast Drop Delta") + .font(.headline) + Text("Only available with Proactive Low. Sets the rate of change threshold (in mg/dL or mmol/L per reading) that triggers a spoken alert even when glucose is still above the Low BG Limit.") + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Speak BG") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { activeInfoSheet = nil } + } + } + } + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + + // MARK: - Helpers + private func markChartSettingsDirty() { Observable.shared.chartSettingsChanged.value = true } diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 4ebc1896a..d33db4fe6 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -22,6 +22,13 @@ struct GraphSettingsView: View { @ObservedObject private var highLine = Storage.shared.highLine @ObservedObject private var downloadDays = Storage.shared.downloadDays + @State private var activeInfoSheet: InfoSheet? + + private enum InfoSheet: Identifiable { + case scale, history + var id: Self { self } + } + private var nightscoutEnabled: Bool { IsNightscoutEnabled() } var body: some View { @@ -87,7 +94,7 @@ struct GraphSettingsView: View { // ── Basal / BG scale ───────────────────────────────────────── if nightscoutEnabled { - Section("Basal / BG Scale") { + Section(header: sectionHeader("Basal / BG Scale", sheet: .scale)) { SettingsStepperRow( title: "Min Basal", range: 0.5 ... 20, @@ -120,7 +127,7 @@ struct GraphSettingsView: View { // ── History window ─────────────────────────────────────────── if nightscoutEnabled { - Section("History") { + Section(header: sectionHeader("History", sheet: .history)) { SettingsStepperRow( title: "Show Days Back", range: 1 ... 4, @@ -131,11 +138,80 @@ struct GraphSettingsView: View { } } } + .sheet(item: $activeInfoSheet) { sheet in + switch sheet { + case .scale: scaleInfoSheet + case .history: historyInfoSheet + } + } } .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme) .navigationBarTitle("Graph Settings", displayMode: .inline) } + // MARK: - Section Header + + private func sectionHeader(_ title: String, sheet: InfoSheet) -> some View { + HStack(spacing: 4) { + Text(title) + Button { + activeInfoSheet = sheet + } label: { + Image(systemName: "info.circle") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.plain) + } + } + + // MARK: - Info Sheets + + private var scaleInfoSheet: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Min Basal") + .font(.headline) + Text("Sets the minimum displayed range for the basal rate plot. The graph will always show at least this range, even if actual basal rates are lower.") + + Text("Min BG Scale") + .font(.headline) + Text("Sets the minimum displayed range for the glucose scale. The graph will always show at least up to this value, preventing the scale from compressing too tightly when glucose values are in a narrow range.") + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("Basal / BG Scale") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { activeInfoSheet = nil } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private var historyInfoSheet: some View { + NavigationStack { + ScrollView { + Text("Controls how many days of history are shown in the small graph and how much data is fetched from your Nightscout site. Higher values show more history but increase data usage.") + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle("History") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { activeInfoSheet = nil } + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + /// Marks the chart as needing a redraw private func markDirty() { Observable.shared.chartSettingsChanged.value = true