Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@
DD13BC752C3FD6210062313B /* InfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC742C3FD6200062313B /* InfoType.swift */; };
DD13BC772C3FD64E0062313B /* InfoData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC762C3FD64E0062313B /* InfoData.swift */; };
DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC782C3FE63A0062313B /* InfoManager.swift */; };
DDA0000012341234000A0002 /* InfoDisplayItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0000012341234000A0001 /* InfoDisplayItem.swift */; };
DDA0000012341234000B0002 /* InfoRowSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA0000012341234000B0001 /* InfoRowSettingsView.swift */; };
DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */; };
DD16AF0D2C98485400FB655A /* SecureStorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */; };
DD16AF0F2C99592F00FB655A /* HKQuantityInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */; };
Expand Down Expand Up @@ -565,6 +567,8 @@
DD13BC742C3FD6200062313B /* InfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoType.swift; sourceTree = "<group>"; };
DD13BC762C3FD64E0062313B /* InfoData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoData.swift; sourceTree = "<group>"; };
DD13BC782C3FE63A0062313B /* InfoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoManager.swift; sourceTree = "<group>"; };
DDA0000012341234000A0001 /* InfoDisplayItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDisplayItem.swift; sourceTree = "<group>"; };
DDA0000012341234000B0001 /* InfoRowSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoRowSettingsView.swift; sourceTree = "<group>"; };
DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoTableView.swift; sourceTree = "<group>"; };
DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorageValue.swift; sourceTree = "<group>"; };
DD16AF0E2C99592F00FB655A /* HKQuantityInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantityInputView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1108,6 +1112,7 @@
DD13BC742C3FD6200062313B /* InfoType.swift */,
DD13BC762C3FD64E0062313B /* InfoData.swift */,
DD13BC782C3FE63A0062313B /* InfoManager.swift */,
DDA0000012341234000A0001 /* InfoDisplayItem.swift */,
DD0C0C652C46E54C00DBADDF /* InfoDataSeparator.swift */,
DD13BC7A2C3FE64A0062313B /* InfoTableView.swift */,
);
Expand Down Expand Up @@ -1190,6 +1195,7 @@
children = (
DD5334262C61668700062F9D /* InfoDisplaySettingsViewModel.swift */,
DD5334282C6166A500062F9D /* InfoDisplaySettingsView.swift */,
DDA0000012341234000B0001 /* InfoRowSettingsView.swift */,
);
path = InfoDisplaySettings;
sourceTree = "<group>";
Expand Down Expand Up @@ -2263,6 +2269,8 @@
DD7F4BA12DD2193F00D449E9 /* AlarmSnoozeSection.swift in Sources */,
DD9ACA0C2D33BB8600415D8A /* CalendarTask.swift in Sources */,
DD13BC792C3FE63A0062313B /* InfoManager.swift in Sources */,
DDA0000012341234000A0002 /* InfoDisplayItem.swift in Sources */,
DDA0000012341234000B0002 /* InfoRowSettingsView.swift in Sources */,
DD13BC7B2C3FE64A0062313B /* InfoTableView.swift in Sources */,
DD4E5F6A7B8C9D0E2F2A3B4C /* RemoteContentView.swift in Sources */,
DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/CAge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ extension MainViewController {
formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale

if let formattedDuration = formatter.string(from: secondsAgo) {
infoManager.updateInfoData(type: .cage, value: formattedDuration)
infoManager.updateInfoData(type: .cage, value: formattedDuration, numericValue: secondsAgo / 86400)
}
}
}
Expand Down
10 changes: 6 additions & 4 deletions LoopFollow/Controllers/Nightscout/DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,13 @@ extension MainViewController {

if let reservoirData = lastPumpRecord["reservoir"] as? Double {
latestPumpVolume = reservoirData
infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U")
infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData)
Storage.shared.lastPumpReservoirU.value = reservoirData
} else {
// Pumps that only report "50+" get treated as exactly 50, both
// for the volume alarm and for the info row's coloring.
latestPumpVolume = 50.0
infoManager.updateInfoData(type: .pump, value: "50+U")
infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0)
Storage.shared.lastPumpReservoirU.value = nil
}
}
Expand All @@ -126,7 +128,7 @@ extension MainViewController {
if let pumpBatteryRecord = lastPumpRecord["battery"] as? [String: AnyObject],
let pumpBatteryPercent = pumpBatteryRecord["percent"] as? Double
{
infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%")
infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent)
Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent
}

Expand All @@ -139,7 +141,7 @@ extension MainViewController {
} else {
batteryText = String(format: "%.0f", upbat) + "%"
}
infoManager.updateInfoData(type: .battery, value: batteryText)
infoManager.updateInfoData(type: .battery, value: batteryText, numericValue: upbat)
Observable.shared.deviceBatteryLevel.value = upbat

let timestamp = uploader["timestamp"] as? Date ?? Date()
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ extension MainViewController {
}
if let recBolus = lastLoopRecord["recommendedBolus"] as? Double {
let formattedRecBolus = String(format: "%.2fU", recBolus)
infoManager.updateInfoData(type: .recBolus, value: formattedRecBolus)
infoManager.updateInfoData(type: .recBolus, value: formattedRecBolus, numericValue: recBolus)
Observable.shared.deviceRecBolus.value = recBolus
}
if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] {
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/IAge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ extension MainViewController {
formatter.zeroFormattingBehavior = [.pad]

if let formattedDuration = formatter.string(from: secondsAgo) {
infoManager.updateInfoData(type: .iage, value: formattedDuration)
infoManager.updateInfoData(type: .iage, value: formattedDuration, numericValue: secondsAgo / 86400)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/SAge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ extension MainViewController {
formatter.zeroFormattingBehavior = [.pad] // Pad with zeroes where appropriate for the locale

if let formattedDuration = formatter.string(from: secondsAgo) {
infoManager.updateInfoData(type: .sage, value: formattedDuration)
infoManager.updateInfoData(type: .sage, value: formattedDuration, numericValue: secondsAgo / 86400)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ extension MainViewController {
}

let resultString = String(format: "%.0f", totalCarbs)
infoManager.updateInfoData(type: .carbsToday, value: resultString)
infoManager.updateInfoData(type: .carbsToday, value: resultString, numericValue: totalCarbs)
Storage.shared.lastCarbsToday.value = totalCarbs
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ extension MainViewController {
updateIage(data: entries)
} else if let iage = currentIage {
updateIage(data: [iage])
} else if Storage.shared.infoVisible.value[InfoType.iage.rawValue] {
} else if Storage.shared.infoDisplayItems.value.isVisible(.iage) {
webLoadNSIage()
}
}
Expand Down
53 changes: 41 additions & 12 deletions LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SwiftUI

struct InfoDisplaySettingsView: View {
@ObservedObject var viewModel: InfoDisplaySettingsViewModel
@State private var selectedID: Int?

var body: some View {
Form {
Expand All @@ -17,28 +18,56 @@ struct InfoDisplaySettingsView: View {
}
}

Section(header: Text("Information Display Settings")) {
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(
header: Text("Information Display Settings"),
footer: Text("Drag to reorder. Tap a row to set its visibility and colors.")
) {
// The list stays in edit mode so rows are always draggable; a
// Button (unlike NavigationLink) still receives taps in edit
// mode, so reordering and navigation work at the same time.
ForEach(viewModel.items) { item in
Button {
selectedID = item.id
} label: {
rowLabel(for: item)
}
.buttonStyle(.plain)
}
.onMove(perform: viewModel.move)
}
}
.environment(\.editMode, .constant(.active))
.navigationDestination(isPresented: Binding(
get: { selectedID != nil },
set: { if !$0 { selectedID = nil } }
)) {
if let id = selectedID {
InfoRowSettingsView(item: viewModel.binding(for: id))
}
}
.onDisappear {
NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
}
.preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
.navigationBarTitle("Information Display Settings", displayMode: .inline)
}

private func rowLabel(for item: InfoDisplayItem) -> some View {
HStack {
Text(item.type.name)
Spacer()
if item.type.isColorable, item.coloring.enabled {
Circle()
.fill(Color.orange)
.frame(width: 8, height: 8)
.accessibilityLabel("Coloring enabled")
}
Text(item.isVisible ? "On" : "Off")
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
}
33 changes: 18 additions & 15 deletions LoopFollow/InfoDisplaySettings/InfoDisplaySettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,31 @@ import Foundation
import SwiftUI

class InfoDisplaySettingsViewModel: ObservableObject {
@Published var infoSort: [Int]
@Published var infoVisible: [Bool]
@Published var items: [InfoDisplayItem]

init() {
infoSort = Storage.shared.infoSort.value
infoVisible = Storage.shared.infoVisible.value
items = Storage.shared.infoDisplayItems.value
}

func toggleVisibility(for sortedIndex: Int) {
infoVisible[sortedIndex].toggle()
Storage.shared.infoVisible.value = infoVisible
func move(from source: IndexSet, to destination: Int) {
items.move(fromOffsets: source, toOffset: destination)
persist()
}

func move(from source: IndexSet, to destination: Int) {
infoSort.move(fromOffsets: source, toOffset: destination)
Storage.shared.infoSort.value = infoSort
/// A binding to a single item, looked up by id so it survives reordering.
/// Writes persist to Storage immediately.
func binding(for id: Int) -> Binding<InfoDisplayItem> {
Binding(
get: { self.items.first(where: { $0.id == id }) ?? self.items[0] },
set: { newValue in
guard let index = self.items.firstIndex(where: { $0.id == id }) else { return }
self.items[index] = newValue
self.persist()
}
)
}

func getName(for index: Int) -> String {
guard let infoType = InfoType(rawValue: index) else {
return "Unknown"
}
return infoType.name
private func persist() {
Storage.shared.infoDisplayItems.value = items
}
}
99 changes: 99 additions & 0 deletions LoopFollow/InfoDisplaySettings/InfoRowSettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// LoopFollow
// InfoRowSettingsView.swift

import SwiftUI

struct InfoRowSettingsView: View {
@Binding var item: InfoDisplayItem

var body: some View {
Form {
Section {
Toggle("Show in Info Display", isOn: $item.isVisible)
}

if let config = item.type.colorConfig {
Section(
header: Text("Color"),
footer: Text(colorFooter(config))
) {
Toggle("Enable coloring", isOn: enabledBinding(config))

if item.coloring.enabled {
thresholdRow(title: "Yellow at", value: $item.coloring.warning, default: config.defaultWarning, config: config)
thresholdRow(title: "Red at", value: $item.coloring.urgent, default: config.defaultUrgent, config: config)

if let thresholdWarning = thresholdWarning(config) {
Label(thresholdWarning, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(.orange)
}
}
}
}
}
.navigationBarTitle(item.type.name, displayMode: .inline)
.preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
}

/// Turning coloring on seeds any unset threshold, so the steppers and the
/// stored values always agree.
private func enabledBinding(_ config: InfoColorConfig) -> Binding<Bool> {
Binding(
get: { item.coloring.enabled },
set: { isOn in
if isOn {
item.coloring.warning = item.coloring.warning ?? config.defaultWarning
item.coloring.urgent = item.coloring.urgent ?? config.defaultUrgent
}
item.coloring.enabled = isOn
}
)
}

private func thresholdRow(title: String, value: Binding<Double?>, default defaultValue: Double, config: InfoColorConfig) -> some View {
SettingsStepperRow(
title: title,
range: config.range,
step: config.step,
value: Binding(
get: { value.wrappedValue ?? defaultValue },
set: { value.wrappedValue = $0 }
),
format: { formatted($0, config) }
)
}

private func formatted(_ value: Double, _ config: InfoColorConfig) -> String {
let number = Localizer.formatToLocalizedString(
value,
maxFractionDigits: config.fractionDigits,
minFractionDigits: config.fractionDigits
)
return "\(number) \(config.unit)"
}

/// Non-blocking sanity check: red should be more severe than yellow, in the
/// metric's fixed direction. `nil` when consistent or a threshold is unset.
private func thresholdWarning(_ config: InfoColorConfig) -> String? {
guard item.coloring.enabled,
let warning = item.coloring.warning,
let urgent = item.coloring.urgent
else { return nil }
switch config.direction {
case .above:
return urgent < warning ? "Red should be at or above yellow." : nil
case .below:
return urgent > warning ? "Red should be at or below yellow." : nil
}
}

private func colorFooter(_ config: InfoColorConfig) -> String {
switch config.direction {
case .above:
return "The value turns yellow at or above the yellow level and red at or above the red level. In range it shows green. A visual cue only — it never triggers an alarm."
case .below:
return "The value turns yellow at or below the yellow level and red at or below the red level. In range it shows green. A visual cue only — it never triggers an alarm."
}
}
}
6 changes: 5 additions & 1 deletion LoopFollow/InfoTable/InfoData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ class InfoData: Identifiable {
let id: Int
let name: String
var value: String
/// Raw numeric value behind `value`, when the row carries a single number.
/// Used for threshold-based coloring; `nil` for text or combined values.
var numericValue: Double?

init(id: Int, name: String, value: String = "") {
init(id: Int, name: String, value: String = "", numericValue: Double? = nil) {
self.id = id
self.name = name
self.value = value
self.numericValue = numericValue
}
}
Loading
Loading