Browse Source

Merge remote-tracking branch 'upstream/dev' into fix-autosens-logs

Marc R Kellerman 1 năm trước cách đây
mục cha
commit
81cef4a214
36 tập tin đã thay đổi với 1038 bổ sung568 xóa
  1. 22 0
      .github/workflows/add_to_project.yml
  2. 48 0
      .github/workflows/stale_issues.yml
  3. 20 19
      .gitmodules
  4. 1 1
      CGMBLEKit
  5. 4 4
      FreeAPS.xcodeproj/project.pbxproj
  6. 30 22
      FreeAPS/Sources/APS/CGM/PluginSource.swift
  7. 22 27
      FreeAPS/Sources/Modules/AddCarbs/View/AddCarbsRootView.swift
  8. 3 3
      FreeAPS/Sources/Modules/AddTempTarget/View/AddTempTargetRootView.swift
  9. 5 6
      FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift
  10. 1 7
      FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift
  11. 16 13
      FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift
  12. 11 7
      FreeAPS/Sources/Modules/FPUConfig/View/FPUConfigRootView.swift
  13. 52 2
      FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift
  14. 1 1
      FreeAPS/Sources/Modules/Home/View/HomeRootView.swift
  15. 6 1
      FreeAPS/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift
  16. 4 0
      FreeAPS/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift
  17. 11 4
      FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConnectView.swift
  18. 4 0
      FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutUploadView.swift
  19. 7 3
      FreeAPS/Sources/Modules/NotificationsConfig/View/NotificationsConfigRootView.swift
  20. 6 16
      FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift
  21. 5 5
      FreeAPS/Sources/Modules/PreferencesEditor/View/PreferencesEditorRootView.swift
  22. 3 3
      FreeAPS/Sources/Modules/PumpSettingsEditor/View/PumpSettingsEditorRootView.swift
  23. 12 12
      FreeAPS/Sources/Modules/Stat/View/ChartsView.swift
  24. 3 3
      FreeAPS/Sources/Modules/StatConfig/View/StatConfigRootView.swift
  25. 9 10
      FreeAPS/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift
  26. 8 1
      FreeAPS/Sources/Services/LiveActivity/LiveActitiyShared.swift
  27. 32 29
      FreeAPS/Sources/Services/LiveActivity/LiveActivityBridge.swift
  28. 1 1
      FreeAPS/Sources/Services/WatchManager/WatchManager.swift
  29. 0 160
      FreeAPS/Sources/Views/DecimalTextField.swift
  30. 311 0
      FreeAPS/Sources/Views/TextFieldWithToolBar.swift
  31. 18 16
      Gemfile.lock
  32. 218 96
      LiveActivity/LiveActivity.swift
  33. 0 66
      LiveActivity/WidgetBobble 2.swift
  34. 1 1
      MinimedKit
  35. 4 6
      fastlane/Fastfile
  36. 139 23
      fastlane/testflight.md

+ 22 - 0
.github/workflows/add_to_project.yml

@@ -0,0 +1,22 @@
+name: 8. DONT RUN Add bugs to bugs project
+
+on:
+  issues:
+    types:
+      - opened
+
+jobs:
+  add-to-project:
+    name: Add issue to project
+    runs-on: ubuntu-latest
+    if: github.repository_owner == 'nightscout'
+    steps:
+      - uses: actions/add-to-project@v1.0.2
+        with:
+          # You can target a project in a different organization
+          # to the issue
+          project-url: https://github.com/orgs/nightscout/projects/2
+          github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
+          labeled: bug, needs-triage
+          label-operator: OR
+

+ 48 - 0
.github/workflows/stale_issues.yml

@@ -0,0 +1,48 @@
+name: 8. DONT RUN close inactive issues
+on:
+  schedule:
+    - cron: "30 1 * * *"
+
+jobs:
+  close-issues:
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
+    if: github.repository_owner == 'nightscout'
+    steps:
+      - uses: actions/stale@v9.0.0
+        with:
+          days-before-issue-stale: 30
+          days-before-issue-close: 14
+          stale-issue-label: "stale"
+          stale-issue-message: "hey 👋 - silence for 30 days 🤐 ... anybody? triage is required!"
+          close-issue-message: "closed 📴 because silencio 🤫 since an additional 14 days after staleness 📠"
+          close-issue-label: "not-planned"
+          exempt-issue-labels: "needs-triage, long-term, in-progress"
+          days-before-pr-stale: -1
+          days-before-pr-close: -1
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+          
+
+  close-issues-triage:
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+      pull-requests: write
+    if: github.repository_owner == 'nightscout'
+    steps:
+      - uses: actions/stale@v9.0.0
+        with:
+          days-before-issue-stale: 30
+          days-before-issue-close: 30
+          stale-issue-label: "stale"
+          stale-issue-message: "hey 👋 - no triage is done for 30 days 🤐 ... anybody? triage is required!"
+          close-issue-message: "closed 📴 because silencio 🤫 since an additional 30 days after staleness 📠"
+          close-issue-label: "not-planned"
+          exempt-issue-labels: "long-term, in-progress"
+          any-of-labels: "needs-triage"
+          days-before-pr-stale: -1
+          days-before-pr-close: -1
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+

+ 20 - 19
.gitmodules

@@ -1,39 +1,40 @@
 [submodule "LoopKit"]
 	path = LoopKit
-	url = https://github.com/LoopKit/LoopKit.git
-	branch = dev
+	url = https://github.com/loopandlearn/LoopKit.git
+	branch = trio
 [submodule "CGMBLEKit"]
 	path = CGMBLEKit
-	url = https://github.com/LoopKit/CGMBLEKit.git
-	branch = dev
+	url = https://github.com/loopandlearn/CGMBLEKit.git
+	branch = trio
 [submodule "dexcom-share-client-swift"]
 	path = dexcom-share-client-swift
-	url = https://github.com/LoopKit/dexcom-share-client-swift.git
-	branch = dev
+	url = https://github.com/loopandlearn/dexcom-share-client-swift.git
+	branch = trio
 [submodule "RileyLinkKit"]
 	path = RileyLinkKit
-	url = https://github.com/LoopKit/RileyLinkKit
-	branch = dev
+	url = https://github.com/loopandlearn/RileyLinkKit
+	branch = trio
 [submodule "OmniBLE"]
 	path = OmniBLE
-	url = https://github.com/LoopKit/OmniBLE.git
-	branch = dev
+	url = https://github.com/loopandlearn/OmniBLE.git
+	branch = trio
 [submodule "G7SensorKit"]
 	path = G7SensorKit
-	url = https://github.com/LoopKit/G7SensorKit.git
-	branch = main
+	url = https://github.com/loopandlearn/G7SensorKit.git
+	branch = trio
 [submodule "OmniKit"]
 	path = OmniKit
-	url = https://github.com/LoopKit/OmniKit.git
-	branch = main
+	url = https://github.com/loopandlearn/OmniKit.git
+	branch = trio
 [submodule "MinimedKit"]
 	path = MinimedKit
-	url = https://github.com/LoopKit/MinimedKit.git
-	branch = main
+	url = https://github.com/loopandlearn/MinimedKit.git
+	branch = trio
 [submodule "LibreTransmitter"]
 	path = LibreTransmitter
-	url = https://github.com/LoopKit/LibreTransmitter.git
-	branch = main
+	url = https://github.com/loopandlearn/LibreTransmitter.git
+	branch = trio
 [submodule "TidepoolService"]
 	path = TidepoolService
-	url = https://github.com/LoopKit/TidepoolService.git
+	url = https://github.com/loopandlearn/TidepoolService.git
+	branch = trio

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit a92e9752994e7b143cdb007d3c7bcba0c0cc9214
+Subproject commit 15af9cf319bff2ac49c361da254ad667461d4687

+ 4 - 4
FreeAPS.xcodeproj/project.pbxproj

@@ -122,7 +122,7 @@
 		3871F38725ED661C0013ECB5 /* Suggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3871F38625ED661C0013ECB5 /* Suggestion.swift */; };
 		3871F39C25ED892B0013ECB5 /* TempTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3871F39B25ED892B0013ECB5 /* TempTarget.swift */; };
 		3871F39F25ED895A0013ECB5 /* Decimal+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3871F39E25ED895A0013ECB5 /* Decimal+Extensions.swift */; };
-		3883581C25EE79BB00E024B2 /* DecimalTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3883581B25EE79BB00E024B2 /* DecimalTextField.swift */; };
+		3883581C25EE79BB00E024B2 /* TextFieldWithToolBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */; };
 		3883583425EEB38000E024B2 /* PumpSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3883583325EEB38000E024B2 /* PumpSettings.swift */; };
 		388358C825EEF6D200E024B2 /* BasalProfileEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */; };
 		38887CCE25F5725200944304 /* IOBEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38887CCD25F5725200944304 /* IOBEntry.swift */; };
@@ -641,7 +641,7 @@
 		3871F38625ED661C0013ECB5 /* Suggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Suggestion.swift; sourceTree = "<group>"; };
 		3871F39B25ED892B0013ECB5 /* TempTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTarget.swift; sourceTree = "<group>"; };
 		3871F39E25ED895A0013ECB5 /* Decimal+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+Extensions.swift"; sourceTree = "<group>"; };
-		3883581B25EE79BB00E024B2 /* DecimalTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecimalTextField.swift; sourceTree = "<group>"; };
+		3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldWithToolBar.swift; sourceTree = "<group>"; };
 		3883583325EEB38000E024B2 /* PumpSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpSettings.swift; sourceTree = "<group>"; };
 		388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalProfileEntry.swift; sourceTree = "<group>"; };
 		38887CCD25F5725200944304 /* IOBEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBEntry.swift; sourceTree = "<group>"; };
@@ -1516,7 +1516,7 @@
 			isa = PBXGroup;
 			children = (
 				3811DE5925C9D4D500A708ED /* ViewModifiers.swift */,
-				3883581B25EE79BB00E024B2 /* DecimalTextField.swift */,
+				3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */,
 				383420D825FFEB3F002D46C1 /* Popup.swift */,
 				389ECDFD2601061500D86C4F /* View+Snapshot.swift */,
 				38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */,
@@ -2719,7 +2719,7 @@
 				38A504A425DD9C4000C5B9E8 /* UserDefaultsExtensions.swift in Sources */,
 				38FE826A25CC82DB001FF17A /* NetworkService.swift in Sources */,
 				FE66D16B291F74F8005D6F77 /* Bundle+Extensions.swift in Sources */,
-				3883581C25EE79BB00E024B2 /* DecimalTextField.swift in Sources */,
+				3883581C25EE79BB00E024B2 /* TextFieldWithToolBar.swift in Sources */,
 				6B1A8D2E2B156EEF00E76752 /* LiveActivityBridge.swift in Sources */,
 				38DAB28A260D349500F74C1A /* FetchGlucoseManager.swift in Sources */,
 				38F37828261260DC009DB701 /* Color+Extensions.swift in Sources */,

+ 30 - 22
FreeAPS/Sources/APS/CGM/PluginSource.swift

@@ -24,7 +24,26 @@ final class PluginSource: GlucoseSource {
         cgmManager?.cgmManagerDelegate = self
     }
 
+    /// Function that fetches blood glucose data
+    /// This function combines two data fetching mechanisms (`callBLEFetch` and `fetchIfNeeded`) into a single publisher.
+    /// It returns the first non-empty result from either of the sources within a 5-minute timeout period.
+    /// If no valid data is fetched within the timeout, it returns an empty array.
+    ///
+    /// - Parameter timer: An optional `DispatchTimer` (not used in the function but can be used to trigger fetch logic).
+    /// - Returns: An `AnyPublisher` that emits an array of `BloodGlucose` values or an empty array if an error occurs or the timeout is reached.
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
+        Publishers.Merge(
+            callBLEFetch(),
+            fetchIfNeeded()
+        )
+        .filter { !$0.isEmpty }
+        .first()
+        .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
+        .replaceError(with: [])
+        .eraseToAnyPublisher()
+    }
+
+    func callBLEFetch() -> AnyPublisher<[BloodGlucose], Never> {
         Future<[BloodGlucose], Error> { [weak self] promise in
             self?.promise = promise
         }
@@ -35,17 +54,15 @@ final class PluginSource: GlucoseSource {
     }
 
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
-        Future<[BloodGlucose], Error> { _ in
+        Future<[BloodGlucose], Error> { [weak self] promise in
+            guard let self = self else { return }
             self.processQueue.async {
                 guard let cgmManager = self.cgmManager else { return }
                 cgmManager.fetchNewDataIfNeeded { result in
-                    self.processCGMReadingResult(cgmManager, readingResult: result) {
-                        // nothing to do
-                    }
+                    promise(self.readCGMResult(readingResult: result))
                 }
             }
         }
-        .timeout(60, scheduler: processQueue, options: nil, customError: nil)
         .replaceError(with: [])
         .replaceEmpty(with: [])
         .eraseToAnyPublisher()
@@ -92,11 +109,10 @@ extension PluginSource: CGMManagerDelegate {
         glucoseManager?.cgmGlucoseSourceType = .none
     }
 
-    func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) {
+    func cgmManager(_: CGMManager, hasNew readingResult: CGMReadingResult) {
         dispatchPrecondition(condition: .onQueue(processQueue))
-        processCGMReadingResult(manager, readingResult: readingResult) {
-            debug(.deviceManager, "CGM PLUGIN - Direct return done")
-        }
+        promise?(readCGMResult(readingResult: readingResult))
+        debug(.deviceManager, "CGM PLUGIN - Direct return done")
     }
 
     func cgmManager(_: LoopKit.CGMManager, hasNew events: [LoopKit.PersistedCgmEvent]) {
@@ -140,11 +156,7 @@ extension PluginSource: CGMManagerDelegate {
         }
     }
 
-    private func processCGMReadingResult(
-        _: CGMManager,
-        readingResult: CGMReadingResult,
-        completion: @escaping () -> Void
-    ) {
+    private func readCGMResult(readingResult: CGMReadingResult) -> Result<[BloodGlucose], Error> {
         debug(.deviceManager, "PLUGIN CGM - Process CGM Reading Result launched with \(readingResult)")
         switch readingResult {
         case let .newData(values):
@@ -177,18 +189,14 @@ extension PluginSource: CGMManagerDelegate {
                     transmitterID: sensorTransmitterID
                 )
             }
-            promise?(.success(bloodGlucose))
-            completion()
+            return .success(bloodGlucose)
         case .unreliableData:
             // loopManager.receivedUnreliableCGMReading()
-            promise?(.failure(GlucoseDataError.unreliableData))
-            completion()
+            return .failure(GlucoseDataError.unreliableData)
         case .noData:
-            promise?(.failure(GlucoseDataError.noData))
-            completion()
+            return .failure(GlucoseDataError.noData)
         case let .error(error):
-            promise?(.failure(error))
-            completion()
+            return .failure(error)
         }
     }
 }

+ 22 - 27
FreeAPS/Sources/Modules/AddCarbs/View/AddCarbsRootView.swift

@@ -42,12 +42,11 @@ extension AddCarbs {
                     HStack {
                         Text("Carbs").fontWeight(.semibold)
                         Spacer()
-                        DecimalTextField(
-                            "0",
-                            value: $state.carbs,
-                            formatter: formatter,
-                            autofocus: true,
-                            cleanInput: true
+                        TextFieldWithToolBar(
+                            text: $state.carbs,
+                            placeholder: "0",
+                            shouldBecomeFirstResponder: true,
+                            numberFormatter: formatter
                         )
                         Text(state.carbs > state.maxCarbs ? "⚠️" : "g").foregroundColor(.secondary)
                     }.padding(.vertical)
@@ -55,14 +54,22 @@ extension AddCarbs {
                     if state.useFPUconversion {
                         proteinAndFat()
                     }
-                    HStack {
-                        Text("Note").foregroundColor(.secondary)
-                        TextField("", text: $state.note).multilineTextAlignment(.trailing)
-                        if isFocused {
-                            Button { isFocused = false } label: { Image(systemName: "keyboard.chevron.compact.down") }
-                                .controlSize(.mini)
+                    VStack {
+                        HStack {
+                            Text("Note").foregroundColor(.secondary)
+                            TextFieldWithToolBarString(text: $state.note, placeholder: "", maxLength: 25)
+                            if isFocused {
+                                Button { isFocused = false } label: { Image(systemName: "keyboard.chevron.compact.down") }
+                                    .controlSize(.mini)
+                            }
+                        }.focused($isFocused)
+
+                        HStack {
+                            Spacer()
+                            Text("\(state.note.count) / 25")
+                                .foregroundStyle(.secondary)
                         }
-                    }.focused($isFocused)
+                    }
                     HStack {
                         Button {
                             state.useFPUconversion.toggle()
@@ -268,25 +275,13 @@ extension AddCarbs {
             HStack {
                 Text("Fat").foregroundColor(.orange) // .fontWeight(.thin)
                 Spacer()
-                DecimalTextField(
-                    "0",
-                    value: $state.fat,
-                    formatter: formatter,
-                    autofocus: false,
-                    cleanInput: true
-                )
+                TextFieldWithToolBar(text: $state.fat, placeholder: "0", numberFormatter: formatter)
                 Text(state.fat > state.maxFat ? "⚠️" : "g").foregroundColor(.secondary)
             }
             HStack {
                 Text("Protein").foregroundColor(.red) // .fontWeight(.thin)
                 Spacer()
-                DecimalTextField(
-                    "0",
-                    value: $state.protein,
-                    formatter: formatter,
-                    autofocus: false,
-                    cleanInput: true
-                )
+                TextFieldWithToolBar(text: $state.protein, placeholder: "0", numberFormatter: formatter)
                 Text(state.protein > state.maxProtein ? "⚠️" : "g").foregroundColor(.secondary)
             }
         }

+ 3 - 3
FreeAPS/Sources/Modules/AddTempTarget/View/AddTempTargetRootView.swift

@@ -187,13 +187,13 @@ extension AddTempTarget {
                     HStack {
                         Text("Target")
                         Spacer()
-                        DecimalTextField("0", value: $state.low, formatter: formatter, cleanInput: true)
+                        TextFieldWithToolBar(text: $state.low, placeholder: "0", numberFormatter: formatter)
                         Text(state.units.rawValue).foregroundColor(.secondary)
                     }
                     HStack {
                         Text("Duration")
                         Spacer()
-                        DecimalTextField("0", value: $state.duration, formatter: formatter, cleanInput: true)
+                        TextFieldWithToolBar(text: $state.duration, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                 }
@@ -203,7 +203,7 @@ extension AddTempTarget {
                     HStack {
                         Text("Duration")
                         Spacer()
-                        DecimalTextField("0", value: $state.duration, formatter: formatter, cleanInput: true)
+                        TextFieldWithToolBar(text: $state.duration, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                 }

+ 5 - 6
FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift

@@ -65,12 +65,11 @@ extension Bolus {
                         HStack {
                             Text("Amount")
                             Spacer()
-                            DecimalTextField(
-                                "0",
-                                value: $state.amount,
-                                formatter: formatter,
-                                autofocus: true,
-                                cleanInput: true
+                            TextFieldWithToolBar(
+                                text: $state.amount,
+                                placeholder: "0",
+                                shouldBecomeFirstResponder: true,
+                                numberFormatter: formatter
                             )
                             Text(state.amount > state.maxBolus ? "⚠️" : "U").foregroundColor(.secondary)
                         }

+ 1 - 7
FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift

@@ -27,13 +27,7 @@ extension Calibrations {
                         HStack {
                             Text("Meter glucose")
                             Spacer()
-                            DecimalTextField(
-                                "0",
-                                value: $state.newCalibration,
-                                formatter: formatter,
-                                autofocus: false,
-                                cleanInput: true
-                            )
+                            TextFieldWithToolBar(text: $state.newCalibration, placeholder: "0", numberFormatter: formatter)
                             Text(state.units.rawValue).foregroundColor(.secondary)
                         }
                         Button {

+ 16 - 13
FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -140,12 +140,11 @@ extension DataTable {
                         Section {
                             HStack {
                                 Text("New Glucose")
-                                DecimalTextField(
-                                    " ... ",
-                                    value: $state.manualGlucose,
-                                    formatter: glucoseFormatter,
-                                    autofocus: true,
-                                    cleanInput: true
+                                TextFieldWithToolBar(
+                                    text: $state.manualGlucose,
+                                    placeholder: " ... ",
+                                    shouldBecomeFirstResponder: true,
+                                    numberFormatter: glucoseFormatter
                                 )
                                 Text(state.units.rawValue).foregroundStyle(.secondary)
                             }
@@ -253,12 +252,11 @@ extension DataTable {
                             HStack {
                                 Text("Amount")
                                 Spacer()
-                                DecimalTextField(
-                                    "0",
-                                    value: $state.externalInsulinAmount,
-                                    formatter: insulinFormatter,
-                                    autofocus: true,
-                                    cleanInput: true
+                                TextFieldWithToolBar(
+                                    text: $state.externalInsulinAmount,
+                                    placeholder: "0",
+                                    shouldBecomeFirstResponder: true,
+                                    numberFormatter: insulinFormatter
                                 )
                                 Text("U").foregroundColor(.secondary)
                             }
@@ -316,7 +314,12 @@ extension DataTable {
                         state.units == .mmolL ? $0.asMmolL : Decimal($0)
                     ) as NSNumber)!
                 } ?? "--")
-                Text(item.glucose.direction?.symbol ?? "--")
+                if item.glucose.type == "Manual" {
+                    Image(systemName: "drop.fill")
+                        .foregroundColor(Color.loopRed)
+                } else {
+                    Text(item.glucose.direction?.symbol ?? "--")
+                }
                 Spacer()
 
                 Text(dateFormatter.string(from: item.glucose.dateString))

+ 11 - 7
FreeAPS/Sources/Modules/FPUConfig/View/FPUConfigRootView.swift

@@ -31,15 +31,15 @@ extension FPUConfig {
                 Section(header: Text("Limit Per Entry")) {
                     HStack {
                         Text("Max Carbs")
-                        DecimalTextField("g", value: $state.maxCarbs, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.maxCarbs, placeholder: "g", numberFormatter: formatter)
                     }
                     HStack {
                         Text("Max Fat")
-                        DecimalTextField("g", value: $state.maxFat, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.maxFat, placeholder: "g", numberFormatter: formatter)
                     }
                     HStack {
                         Text("Max Protein")
-                        DecimalTextField("g", value: $state.maxProtein, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.maxProtein, placeholder: "g", numberFormatter: formatter)
                     }
                 }
 
@@ -47,22 +47,26 @@ extension FPUConfig {
                     HStack {
                         Text("Delay In Minutes")
                         Spacer()
-                        DecimalTextField("60", value: $state.delay, formatter: intFormater)
+                        TextFieldWithToolBar(text: $state.delay, placeholder: "60", numberFormatter: intFormater)
                     }
                     HStack {
                         Text("Maximum Duration In Hours")
                         Spacer()
-                        DecimalTextField("8", value: $state.timeCap, formatter: intFormater)
+                        TextFieldWithToolBar(text: $state.timeCap, placeholder: "8", numberFormatter: intFormater)
                     }
                     HStack {
                         Text("Interval In Minutes")
                         Spacer()
-                        DecimalTextField("30", value: $state.minuteInterval, formatter: intFormater)
+                        TextFieldWithToolBar(text: $state.minuteInterval, placeholder: "30", numberFormatter: intFormater)
                     }
                     HStack {
                         Text("Override With A Factor Of ")
                         Spacer()
-                        DecimalTextField("0.5", value: $state.individualAdjustmentFactor, formatter: conversionFormatter)
+                        TextFieldWithToolBar(
+                            text: $state.individualAdjustmentFactor,
+                            placeholder: "0.5",
+                            numberFormatter: conversionFormatter
+                        )
                     }
                 }
 

+ 52 - 2
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -57,6 +57,7 @@ struct MainChartView: View {
     @State var didAppearTrigger = false
     @State private var glucoseDots: [CGRect] = []
     @State private var unSmoothedGlucoseDots: [CGRect] = []
+    @State private var manualGlucoseDots: [CGRect] = []
     @State private var predictionDots: [PredictionType: [CGRect]] = [:]
     @State private var bolusDots: [DotInfo] = []
     @State private var bolusPath = Path()
@@ -270,6 +271,7 @@ struct MainChartView: View {
                     bolusView(fullSize: fullSize)
                     if smooth { unSmoothedGlucoseView(fullSize: fullSize) }
                     glucoseView(fullSize: fullSize)
+                    manualGlucoseView(fullSize: fullSize)
                     predictionsView(fullSize: fullSize)
                 }
                 timeLabelsView(fullSize: fullSize)
@@ -363,6 +365,32 @@ struct MainChartView: View {
         }
     }
 
+    private func manualGlucoseView(fullSize: CGSize) -> some View {
+        ZStack {
+            Path { path in
+                for rect in manualGlucoseDots {
+                    path.addEllipse(in: rect)
+                }
+            }
+            .fill(Color.loopRed)
+            Path { path in
+                for rect in manualGlucoseDots {
+                    path.addEllipse(in: rect)
+                }
+            }
+            .stroke(Color.primary, lineWidth: 0.5)
+        }
+        .onChange(of: glucose) { _ in
+            update(fullSize: fullSize)
+        }
+        .onChange(of: didAppearTrigger) { _ in
+            update(fullSize: fullSize)
+        }
+        .onReceive(Foundation.NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
+            update(fullSize: fullSize)
+        }
+    }
+
     private func bolusView(fullSize: CGSize) -> some View {
         ZStack {
             bolusPath
@@ -489,6 +517,7 @@ extension MainChartView {
         calculatePredictionDots(fullSize: fullSize, type: .uam)
         calculateGlucoseDots(fullSize: fullSize)
         calculateUnSmoothedGlucoseDots(fullSize: fullSize)
+        calculateManualGlucoseDots(fullSize: fullSize)
         calculateBolusDots(fullSize: fullSize)
         calculateCarbsDots(fullSize: fullSize)
         calculateFPUsDots(fullSize: fullSize)
@@ -499,7 +528,10 @@ extension MainChartView {
 
     private func calculateGlucoseDots(fullSize: CGSize) {
         calculationQueue.async {
-            let dots = glucose.concurrentMap { value -> CGRect in
+            let sgvs = glucose
+                .filter { $0.type != "Manual"
+                } // as fingerpricks will be drawn differently, slightly larger and red - so do not draw them here
+            let dots = sgvs.concurrentMap { value -> CGRect in
                 let position = glucoseToCoordinate(value, fullSize: fullSize)
                 return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
             }
@@ -515,7 +547,8 @@ extension MainChartView {
 
     private func calculateUnSmoothedGlucoseDots(fullSize: CGSize) {
         calculationQueue.async {
-            let dots = glucose.concurrentMap { value -> CGRect in
+            let sgvs = glucose.filter { $0.type == "sgv" }
+            let dots = sgvs.concurrentMap { value -> CGRect in
                 let position = UnSmoothedGlucoseToCoordinate(value, fullSize: fullSize)
                 return CGRect(x: position.x - 2, y: position.y - 2, width: 4, height: 4)
             }
@@ -529,6 +562,23 @@ extension MainChartView {
         }
     }
 
+    private func calculateManualGlucoseDots(fullSize: CGSize) {
+        calculationQueue.async {
+            let manuals = glucose.filter { $0.type == "Manual" }
+            let dots = manuals.concurrentMap { value -> CGRect in
+                let position = glucoseToCoordinate(value, fullSize: fullSize)
+                return CGRect(x: position.x - 2, y: position.y - 2, width: 6, height: 6)
+            }
+
+            let range = self.getGlucoseYRange(fullSize: fullSize)
+
+            DispatchQueue.main.async {
+                glucoseYRange = range
+                manualGlucoseDots = dots
+            }
+        }
+    }
+
     private func calculateBolusDots(fullSize: CGSize) {
         calculationQueue.async {
             let dots = boluses.map { value -> DotInfo in

+ 1 - 1
FreeAPS/Sources/Modules/Home/View/HomeRootView.swift

@@ -344,7 +344,7 @@ extension Home {
 
                     if let eventualBG = state.eventualBG {
                         Text(
-                            "⇢ " + numberFormatter.string(
+                            "⇢ " + targetFormatter.string(
                                 from: (state.units == .mmolL ? eventualBG.asMmolL : Decimal(eventualBG)) as NSNumber
                             )!
                         )

+ 6 - 1
FreeAPS/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift

@@ -19,7 +19,12 @@ extension ManualTempBasal {
                     HStack {
                         Text("Amount")
                         Spacer()
-                        DecimalTextField("0", value: $state.rate, formatter: formatter, autofocus: true, cleanInput: true)
+                        TextFieldWithToolBar(
+                            text: $state.rate,
+                            placeholder: "0",
+                            shouldBecomeFirstResponder: true,
+                            numberFormatter: formatter
+                        )
                         Text("U/hr").foregroundColor(.secondary)
                     }
                     Picker(selection: $state.durationIndex, label: Text("Duration")) {

+ 4 - 0
FreeAPS/Sources/Modules/NightscoutConfig/NightscoutConfigStateModel.swift

@@ -51,6 +51,10 @@ extension NightscoutConfig {
         }
 
         func connect() {
+            if let CheckURL = url.last, CheckURL == "/" {
+                let fixedURL = url.dropLast()
+                url = String(fixedURL)
+            }
             guard let url = URL(string: url) else {
                 message = "Invalid URL"
                 return

+ 11 - 4
FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConnectView.swift

@@ -2,12 +2,13 @@ import SwiftUI
 
 struct NightscoutConnectView: View {
     @ObservedObject var state: NightscoutConfig.StateModel
-    @State private var portFormater: NumberFormatter
+    @State private var portFormatter: NumberFormatter
 
     init(state: NightscoutConfig.StateModel) {
         self.state = state
-        portFormater = NumberFormatter()
-        portFormater.allowsFloats = false
+        portFormatter = NumberFormatter()
+        portFormatter.allowsFloats = false
+        portFormatter.usesGroupingSeparator = false
     }
 
     var body: some View {
@@ -49,7 +50,13 @@ struct NightscoutConnectView: View {
                 Toggle("Use local glucose server", isOn: $state.useLocalSource)
                 HStack {
                     Text("Port")
-                    DecimalTextField("", value: $state.localPort, formatter: portFormater)
+                    TextFieldWithToolBar(
+                        text: $state.localPort,
+                        placeholder: "",
+                        keyboardType: .numberPad,
+                        numberFormatter: portFormatter,
+                        allowDecimalSeparator: false
+                    )
                 }
             } header: { Text("Local glucose source") }
         }

+ 4 - 0
FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutUploadView.swift

@@ -13,6 +13,10 @@ struct NightscoutUploadView: View {
                         "The Upload Treatments toggle enables uploading of carbs, temp targets, device status, preferences and settings."
                     )
                     Text("\nThe Upload Glucose toggle enables uploading of CGM readings.")
+
+                    if !state.changeUploadGlucose {
+                        Text("\nTo flip the Upload Glucose toggle, go to ⚙️ > CGM > CGM Configuration")
+                    }
                 }
             )
                 {

+ 7 - 3
FreeAPS/Sources/Modules/NotificationsConfig/View/NotificationsConfigRootView.swift

@@ -89,14 +89,14 @@ extension NotificationsConfig {
                     HStack {
                         Text("Low")
                         Spacer()
-                        DecimalTextField("0", value: $state.lowGlucose, formatter: glucoseFormatter)
+                        TextFieldWithToolBar(text: $state.lowGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
                         Text(state.units.rawValue).foregroundColor(.secondary)
                     }
 
                     HStack {
                         Text("High")
                         Spacer()
-                        DecimalTextField("0", value: $state.highGlucose, formatter: glucoseFormatter)
+                        TextFieldWithToolBar(text: $state.highGlucose, placeholder: "0", numberFormatter: glucoseFormatter)
                         Text(state.units.rawValue).foregroundColor(.secondary)
                     }
                 }
@@ -105,7 +105,11 @@ extension NotificationsConfig {
                     HStack {
                         Text("Carbs Required Threshold")
                         Spacer()
-                        DecimalTextField("0", value: $state.carbsRequiredThreshold, formatter: carbsFormatter)
+                        TextFieldWithToolBar(
+                            text: $state.carbsRequiredThreshold,
+                            placeholder: "0",
+                            numberFormatter: carbsFormatter
+                        )
                         Text("g").foregroundColor(.secondary)
                     }
                 }

+ 6 - 16
FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift

@@ -135,7 +135,7 @@ extension OverrideProfilesConfig {
                 if !state._indefinite {
                     HStack {
                         Text("Duration")
-                        DecimalTextField("0", value: $state.duration, formatter: formatter, cleanInput: false)
+                        TextFieldWithToolBar(text: $state.duration, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                 }
@@ -148,7 +148,7 @@ extension OverrideProfilesConfig {
                 if state.override_target {
                     HStack {
                         Text("Target Glucose")
-                        DecimalTextField("0", value: $state.target, formatter: glucoseFormatter, cleanInput: false)
+                        TextFieldWithToolBar(text: $state.target, placeholder: "0", numberFormatter: glucoseFormatter)
                         Text(state.units.rawValue).foregroundColor(.secondary)
                     }
                 }
@@ -172,12 +172,12 @@ extension OverrideProfilesConfig {
                         if state.smbIsScheduledOff {
                             HStack {
                                 Text("First Hour SMBs are Off (24 hours)")
-                                DecimalTextField("0", value: $state.start, formatter: formatter, cleanInput: false)
+                                TextFieldWithToolBar(text: $state.start, placeholder: "0", numberFormatter: formatter)
                                 Text("hour").foregroundColor(.secondary)
                             }
                             HStack {
                                 Text("First Hour SMBs are Resumed (24 hours)")
-                                DecimalTextField("0", value: $state.end, formatter: formatter, cleanInput: false)
+                                TextFieldWithToolBar(text: $state.end, placeholder: "0", numberFormatter: formatter)
                                 Text("hour").foregroundColor(.secondary)
                             }
                         }
@@ -201,22 +201,12 @@ extension OverrideProfilesConfig {
                     }
                     HStack {
                         Text("SMB Minutes")
-                        DecimalTextField(
-                            "0",
-                            value: $state.smbMinutes,
-                            formatter: formatter,
-                            cleanInput: false
-                        )
+                        TextFieldWithToolBar(text: $state.smbMinutes, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                     HStack {
                         Text("UAM SMB Minutes")
-                        DecimalTextField(
-                            "0",
-                            value: $state.uamMinutes,
-                            formatter: formatter,
-                            cleanInput: false
-                        )
+                        TextFieldWithToolBar(text: $state.uamMinutes, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                 }

+ 5 - 5
FreeAPS/Sources/Modules/PreferencesEditor/View/PreferencesEditorRootView.swift

@@ -29,7 +29,7 @@ extension PreferencesEditor {
                     }
                     HStack {
                         Text("Recommended Bolus Percentage")
-                        DecimalTextField("", value: $state.insulinReqPercentage, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.insulinReqPercentage, placeholder: "", numberFormatter: formatter)
                     }
 
                     Toggle("Skip Bolus screen after carbs", isOn: $state.skipBolusScreenAfterCarbs)
@@ -62,10 +62,10 @@ extension PreferencesEditor {
                                         })
                                         Text(field.displayName)
                                     }
-                                    DecimalTextField(
-                                        "0",
-                                        value: self.$state.sections[sectionIndex].fields[fieldIndex].decimalValue,
-                                        formatter: formatter
+                                    TextFieldWithToolBar(
+                                        text: self.$state.sections[sectionIndex].fields[fieldIndex].decimalValue,
+                                        placeholder: "0",
+                                        numberFormatter: formatter
                                     )
                                 case .insulinCurve:
                                     Picker(

+ 3 - 3
FreeAPS/Sources/Modules/PumpSettingsEditor/View/PumpSettingsEditorRootView.swift

@@ -17,18 +17,18 @@ extension PumpSettingsEditor {
                 Section(header: Text("Delivery limits")) {
                     HStack {
                         Text("Max Basal")
-                        DecimalTextField("U/hr", value: $state.maxBasal, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.maxBasal, placeholder: "U/hr", numberFormatter: formatter)
                     }
                     HStack {
                         Text("Max Bolus")
-                        DecimalTextField("U", value: $state.maxBolus, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.maxBolus, placeholder: "U", numberFormatter: formatter)
                     }
                 }
 
                 Section(header: Text("Duration of Insulin Action")) {
                     HStack {
                         Text("DIA")
-                        DecimalTextField("hours", value: $state.dia, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.dia, placeholder: "hours", numberFormatter: formatter)
                     }
                 }
 

+ 12 - 12
FreeAPS/Sources/Modules/Stat/View/ChartsView.swift

@@ -114,7 +114,7 @@ struct ChartsView: View {
                 type: NSLocalizedString(
                     "Low",
                     comment: ""
-                ) + " (\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                ) + " (<\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
                 percent: fetched[0].decimal
             ),
             .init(type: NSLocalizedString("In Range", comment: ""), percent: fetched[1].decimal),
@@ -122,7 +122,7 @@ struct ChartsView: View {
                 type: NSLocalizedString(
                     "High",
                     comment: ""
-                ) + " (\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                ) + " (>\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
                 percent: fetched[2].decimal
             )
         ]
@@ -142,12 +142,12 @@ struct ChartsView: View {
             NSLocalizedString(
                 "Low",
                 comment: ""
-            ) + " (\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .red,
+            ) + " (<\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .red,
             NSLocalizedString("In Range", comment: ""): .green,
             NSLocalizedString(
                 "High",
                 comment: ""
-            ) + " (\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .orange
+            ) + " (>\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .orange
         ]).frame(maxHeight: 25)
     }
 
@@ -161,18 +161,18 @@ struct ChartsView: View {
                 type: NSLocalizedString(
                     "Low",
                     comment: ""
-                ) + " ( \(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                ) + " (< \(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
                 percent: fetched[0].decimal
             ),
             .init(
-                type: "> \(low.formatted(.number.precision(.fractionLength(fraction)))) - < \(high.formatted(.number.precision(.fractionLength(fraction))))",
+                type: "\(low.formatted(.number.precision(.fractionLength(fraction)))) - \(high.formatted(.number.precision(.fractionLength(fraction))))",
                 percent: fetched[1].decimal
             ),
             .init(
                 type: NSLocalizedString(
                     "High",
                     comment: ""
-                ) + " ( \(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
+                ) + " (> \(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))",
                 percent: fetched[2].decimal
             )
         ]
@@ -196,12 +196,12 @@ struct ChartsView: View {
             NSLocalizedString(
                 "Low",
                 comment: ""
-            ) + " ( \(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .red,
-            "> \(low.formatted(.number.precision(.fractionLength(fraction)))) - < \(high.formatted(.number.precision(.fractionLength(fraction))))": .green,
+            ) + " (< \(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .red,
+            "\(low.formatted(.number.precision(.fractionLength(fraction)))) - \(high.formatted(.number.precision(.fractionLength(fraction))))": .green,
             NSLocalizedString(
                 "High",
                 comment: ""
-            ) + " ( \(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .orange
+            ) + " (> \(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))))": .orange
         ])
     }
 
@@ -276,11 +276,11 @@ struct ChartsView: View {
         let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
         let totalReadings = justGlucoseArray.count
 
-        let hyperArray = glucose.filter({ $0.glucose >= hyperLimit })
+        let hyperArray = glucose.filter({ $0.glucose > hyperLimit })
         let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
         let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
 
-        let hypoArray = glucose.filter({ $0.glucose <= hypoLimit })
+        let hypoArray = glucose.filter({ $0.glucose < hypoLimit })
         let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
         let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
 

+ 3 - 3
FreeAPS/Sources/Modules/StatConfig/View/StatConfigRootView.swift

@@ -36,21 +36,21 @@ extension StatConfig {
                     HStack {
                         Text("Hours X-Axis (6 default)")
                         Spacer()
-                        DecimalTextField("6", value: $state.hours, formatter: carbsFormatter)
+                        TextFieldWithToolBar(text: $state.hours, placeholder: "6", numberFormatter: carbsFormatter)
                         Text("hours").foregroundColor(.secondary)
                     }
 
                     HStack {
                         Text("Low")
                         Spacer()
-                        DecimalTextField("0", value: $state.low, formatter: glucoseFormatter)
+                        TextFieldWithToolBar(text: $state.low, placeholder: "0", numberFormatter: glucoseFormatter)
                         Text(state.units.rawValue).foregroundColor(.secondary)
                     }
 
                     HStack {
                         Text("High")
                         Spacer()
-                        DecimalTextField("0", value: $state.high, formatter: glucoseFormatter)
+                        TextFieldWithToolBar(text: $state.high, placeholder: "0", numberFormatter: glucoseFormatter)
                         Text(state.units.rawValue).foregroundColor(.secondary)
                     }
                 }

+ 9 - 10
FreeAPS/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift

@@ -6,7 +6,7 @@ extension TargetsEditor {
 
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
-        var rateValues: [Double] {
+        var rateValues: [Decimal] {
             switch units {
             case .mgdL:
                 return stride(from: 72, to: 180.01, by: 1.0).map { $0 }
@@ -27,8 +27,8 @@ extension TargetsEditor {
             units = profile.units
             items = profile.targets.map { value in
                 let timeIndex = timeValues.firstIndex(of: Double(value.offset * 60)) ?? 0
-                let lowIndex = rateValues.firstIndex(of: Double(value.low)) ?? 0
-                let highIndex = lowIndex
+                let lowIndex = rateValues.firstIndex(of: value.low) ?? 0
+                let highIndex = rateValues.firstIndex(of: value.high) ?? 0
                 return Item(lowIndex: lowIndex, highIndex: highIndex, timeIndex: timeIndex)
             }
         }
@@ -50,14 +50,14 @@ extension TargetsEditor {
 
         func save() {
             let targets = items.map { item -> BGTargetEntry in
-                let fotmatter = DateFormatter()
-                fotmatter.timeZone = TimeZone(secondsFromGMT: 0)
-                fotmatter.dateFormat = "HH:mm:ss"
+                let formatter = DateFormatter()
+                formatter.timeZone = TimeZone(secondsFromGMT: 0)
+                formatter.dateFormat = "HH:mm:ss"
                 let date = Date(timeIntervalSince1970: self.timeValues[item.timeIndex])
                 let minutes = Int(date.timeIntervalSince1970 / 60)
-                let low = Decimal(self.rateValues[item.lowIndex])
+                let low = self.rateValues[item.lowIndex]
                 let high = low
-                return BGTargetEntry(low: low, high: high, start: fotmatter.string(from: date), offset: minutes)
+                return BGTargetEntry(low: low, high: high, start: formatter.string(from: date), offset: minutes)
             }
             let profile = BGTargets(units: units, userPrefferedUnits: settingsManager.settings.units, targets: targets)
             provider.saveProfile(profile)
@@ -68,8 +68,7 @@ extension TargetsEditor {
                 let uniq = Array(Set(self.items))
                 let sorted = uniq.sorted { $0.timeIndex < $1.timeIndex }
                     .map { item -> Item in
-                        guard item.highIndex < item.lowIndex else { return item }
-                        return Item(lowIndex: item.lowIndex, highIndex: item.lowIndex, timeIndex: item.timeIndex)
+                        Item(lowIndex: item.lowIndex, highIndex: item.highIndex, timeIndex: item.timeIndex)
                     }
                 sorted.first?.timeIndex = 0
                 self.items = sorted

+ 8 - 1
FreeAPS/Sources/Services/LiveActivity/LiveActitiyShared.swift

@@ -7,6 +7,14 @@ struct LiveActivityAttributes: ActivityAttributes {
         let direction: String?
         let change: String
         let date: Date
+
+        let detailedViewState: ContentAdditionalState?
+
+        /// true for the first state that is set on the activity
+        let isInitialState: Bool
+    }
+
+    public struct ContentAdditionalState: Codable, Hashable {
         let chart: [Double]
         let chartDate: [Date?]
         let rotationDegrees: Double
@@ -14,7 +22,6 @@ struct LiveActivityAttributes: ActivityAttributes {
         let lowGlucose: Double
         let cob: Decimal
         let iob: Decimal
-        let lockScreenView: String
     }
 
     let startDate: Date

+ 32 - 29
FreeAPS/Sources/Services/LiveActivity/LiveActivityBridge.swift

@@ -65,35 +65,44 @@ extension LiveActivityAttributes.ContentState {
             Self.formatGlucose(glucose - $0, mmol: mmol, forceSign: true)
         }) ?? ""
 
-        let chartBG = chart.map(\.glucose)
+        let detailedState: LiveActivityAttributes.ContentAdditionalState?
 
-        let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
-        let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
+        switch settings.lockScreenView {
+        case .detailed:
+            let chartBG = chart.map(\.glucose)
 
-        let chartDate = chart.map(\.date)
+            let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
+            let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
 
-        /// glucose limits from UI settings
-        let highGlucose = settings.high / Decimal(conversionFactor)
-        let lowGlucose = settings.low / Decimal(conversionFactor)
+            let chartDate = chart.map(\.date)
 
-        let cob = suggestion.cob ?? 0
-        let iob = suggestion.iob ?? 0
+            /// glucose limits from UI settings
+            let highGlucose = settings.high / Decimal(conversionFactor)
+            let lowGlucose = settings.low / Decimal(conversionFactor)
 
-        let lockScreenView = settings.lockScreenView.displayName
+            let cob = suggestion.cob ?? 0
+            let iob = suggestion.iob ?? 0
+
+            detailedState = LiveActivityAttributes.ContentAdditionalState(
+                chart: convertedChartBG,
+                chartDate: chartDate,
+                rotationDegrees: rotationDegrees,
+                highGlucose: Double(highGlucose),
+                lowGlucose: Double(lowGlucose),
+                cob: cob,
+                iob: iob
+            )
+        case .simple:
+            detailedState = nil
+        }
 
         self.init(
             bg: formattedBG,
             direction: trendString,
             change: change,
             date: bg.dateString,
-            chart: convertedChartBG,
-            chartDate: chartDate,
-            rotationDegrees: rotationDegrees,
-            highGlucose: Double(highGlucose),
-            lowGlucose: Double(lowGlucose),
-            cob: cob,
-            iob: iob,
-            lockScreenView: lockScreenView
+            detailedViewState: detailedState,
+            isInitialState: false
         )
     }
 }
@@ -216,28 +225,22 @@ extension LiveActivityAttributes.ContentState {
             do {
                 // always push a non-stale content as the first update
                 // pushing a stale content as the frst content results in the activity not being shown at all
-                // we want it shown though even if it is iniially stale, as we expect new BG readings to become available soon, which should then be displayed
-                let nonStale = ActivityContent(
+                // apparently this initial state is also what is shown after the live activity expires (after 8h)
+                let expired = ActivityContent(
                     state: LiveActivityAttributes.ContentState(
                         bg: "--",
                         direction: nil,
                         change: "--",
                         date: Date.now,
-                        chart: [],
-                        chartDate: [],
-                        rotationDegrees: 0,
-                        highGlucose: Double(180),
-                        lowGlucose: Double(70),
-                        cob: 0,
-                        iob: 0,
-                        lockScreenView: "Simple"
+                        detailedViewState: nil,
+                        isInitialState: true
                     ),
                     staleDate: Date.now.addingTimeInterval(60)
                 )
 
                 let activity = try Activity.request(
                     attributes: LiveActivityAttributes(startDate: Date.now),
-                    content: nonStale,
+                    content: expired,
                     pushType: nil
                 )
                 currentActivity = ActiveActivity(activity: activity, startDate: Date.now)

+ 1 - 1
FreeAPS/Sources/Services/WatchManager/WatchManager.swift

@@ -216,7 +216,7 @@ final class BaseWatchManager: NSObject, WatchManager, Injectable {
     private var eventualFormatter: NumberFormatter {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 2
+        formatter.maximumFractionDigits = 1
         return formatter
     }
 

+ 0 - 160
FreeAPS/Sources/Views/DecimalTextField.swift

@@ -1,160 +0,0 @@
-import Combine
-import SwiftUI
-
-struct DecimalTextField: UIViewRepresentable {
-    private var placeholder: String
-    @Binding var value: Decimal
-    private var formatter: NumberFormatter
-    private var autofocus: Bool
-    private var cleanInput: Bool
-
-    init(
-        _ placeholder: String,
-        value: Binding<Decimal>,
-        formatter: NumberFormatter,
-        autofocus: Bool = false,
-        cleanInput: Bool = false
-    ) {
-        self.placeholder = placeholder
-        _value = value
-        self.formatter = formatter
-        self.autofocus = autofocus
-        self.cleanInput = cleanInput
-    }
-
-    func makeUIView(context: Context) -> UITextField {
-        let textfield = UITextField()
-        textfield.keyboardType = .decimalPad
-        textfield.delegate = context.coordinator
-        textfield.placeholder = placeholder
-        textfield.text = cleanInput ? "" : formatter.string(for: value) ?? placeholder
-        textfield.textAlignment = .right
-
-        let toolBar = UIToolbar(frame: CGRect(
-            x: 0,
-            y: 0,
-            width: textfield.frame.size.width,
-            height: 44
-        ))
-        let clearButton = UIBarButtonItem(
-            title: NSLocalizedString("Clear", comment: "Clear button"),
-            style: .plain,
-            target: self,
-            action: #selector(textfield.clearButtonTapped(button:))
-        )
-        let doneButton = UIBarButtonItem(
-            title: NSLocalizedString("Done", comment: "Done button"),
-            style: .done,
-            target: self,
-            action: #selector(textfield.doneButtonTapped(button:))
-        )
-        let space = UIBarButtonItem(
-            barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace,
-            target: nil,
-            action: nil
-        )
-        toolBar.setItems([clearButton, space, doneButton], animated: true)
-        textfield.inputAccessoryView = toolBar
-        if autofocus {
-            DispatchQueue.main.async {
-                textfield.becomeFirstResponder()
-            }
-        }
-        return textfield
-    }
-
-    func updateUIView(_ textField: UITextField, context: Context) {
-        let coordinator = context.coordinator
-        if coordinator.isEditing {
-            coordinator.resetEditing()
-        } else if value == 0 {
-            textField.text = ""
-        } else {
-            textField.text = formatter.string(for: value)
-        }
-    }
-
-    func makeCoordinator() -> Coordinator {
-        Coordinator(self)
-    }
-
-    class Coordinator: NSObject, UITextFieldDelegate {
-        var parent: DecimalTextField
-
-        init(_ textField: DecimalTextField) {
-            parent = textField
-        }
-
-        private(set) var isEditing = false
-        private var editingCancellable: AnyCancellable?
-
-        func resetEditing() {
-            editingCancellable = Just(false)
-                .delay(for: 0.5, scheduler: DispatchQueue.main)
-                .weakAssign(to: \.isEditing, on: self)
-        }
-
-        func textField(
-            _ textField: UITextField,
-            shouldChangeCharactersIn range: NSRange,
-            replacementString string: String
-        ) -> Bool {
-            // Allow only numbers and decimal characters
-            let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
-            let withDecimal = (
-                string == NumberFormatter().decimalSeparator &&
-                    textField.text?.contains(string) == false
-            )
-
-            if isNumber || withDecimal,
-               let currentValue = textField.text as NSString?
-            {
-                // Update Value
-                let proposedValue = currentValue.replacingCharacters(in: range, with: string) as String
-
-                let decimalFormatter = NumberFormatter()
-                decimalFormatter.locale = Locale.current
-                decimalFormatter.numberStyle = .decimal
-
-                // Try currency formatter then Decimal formatrer
-                let number = parent.formatter.number(from: proposedValue) ?? decimalFormatter.number(from: proposedValue) ?? 0.0
-
-                // Set Value
-                let double = number.doubleValue
-                isEditing = true
-                parent.value = Decimal(double)
-            }
-
-            return isNumber || withDecimal
-        }
-
-        func textFieldDidEndEditing(
-            _ textField: UITextField,
-            reason _: UITextField.DidEndEditingReason
-        ) {
-            // Format value with formatter at End Editing
-            textField.text = parent.formatter.string(for: parent.value)
-            isEditing = false
-        }
-    }
-}
-
-// MARK: extension for done button
-
-extension UITextField {
-    @objc func doneButtonTapped(button _: UIBarButtonItem) {
-        resignFirstResponder()
-    }
-
-    @objc func clearButtonTapped(button _: UIBarButtonItem) {
-        text = ""
-    }
-}
-
-// MARK: extension for keyboard to dismiss
-
-extension UIApplication {
-    func endEditing() {
-        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
-    }
-}

+ 311 - 0
FreeAPS/Sources/Views/TextFieldWithToolBar.swift

@@ -0,0 +1,311 @@
+import SwiftUI
+import UIKit
+
+public struct TextFieldWithToolBar: UIViewRepresentable {
+    @Binding var text: Decimal
+    var placeholder: String
+    var textColor: UIColor
+    var textAlignment: NSTextAlignment
+    var keyboardType: UIKeyboardType
+    var autocapitalizationType: UITextAutocapitalizationType
+    var autocorrectionType: UITextAutocorrectionType
+    var shouldBecomeFirstResponder: Bool
+    var maxLength: Int?
+    var isDismissible: Bool
+    var textFieldDidBeginEditing: (() -> Void)?
+    var numberFormatter: NumberFormatter
+    var allowDecimalSeparator: Bool
+
+    public init(
+        text: Binding<Decimal>,
+        placeholder: String,
+        textColor: UIColor = .label,
+        textAlignment: NSTextAlignment = .right,
+        keyboardType: UIKeyboardType = .decimalPad,
+        autocapitalizationType: UITextAutocapitalizationType = .none,
+        autocorrectionType: UITextAutocorrectionType = .no,
+        shouldBecomeFirstResponder: Bool = false,
+        maxLength: Int? = nil,
+        isDismissible: Bool = true,
+        textFieldDidBeginEditing: (() -> Void)? = nil,
+        numberFormatter: NumberFormatter,
+        allowDecimalSeparator: Bool = true
+    ) {
+        _text = text
+        self.placeholder = placeholder
+        self.textColor = textColor
+        self.textAlignment = textAlignment
+        self.keyboardType = keyboardType
+        self.autocapitalizationType = autocapitalizationType
+        self.autocorrectionType = autocorrectionType
+        self.shouldBecomeFirstResponder = shouldBecomeFirstResponder
+        self.maxLength = maxLength
+        self.isDismissible = isDismissible
+        self.textFieldDidBeginEditing = textFieldDidBeginEditing
+        self.numberFormatter = numberFormatter
+        self.numberFormatter.numberStyle = .decimal
+        self.allowDecimalSeparator = allowDecimalSeparator
+    }
+
+    public func makeUIView(context: Context) -> UITextField {
+        let textField = UITextField()
+        context.coordinator.textField = textField
+        textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
+        textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
+        textField.delegate = context.coordinator
+        if text == 0 { /// show no value initially, i.e. empty String
+            textField.text = ""
+        } else {
+            textField.text = numberFormatter.string(for: text)
+        }
+        textField.placeholder = placeholder
+        return textField
+    }
+
+    private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
+        let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
+        let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
+        let doneButton = UIBarButtonItem(
+            image: UIImage(systemName: "keyboard.chevron.compact.down"),
+            style: .done,
+            target: textField,
+            action: #selector(UITextField.resignFirstResponder)
+        )
+        let clearButton = UIBarButtonItem(
+            image: UIImage(systemName: "trash"),
+            style: .plain,
+            target: context.coordinator,
+            action: #selector(Coordinator.clearText)
+        )
+
+        toolbar.items = [clearButton, flexibleSpace, doneButton]
+        toolbar.sizeToFit()
+        return toolbar
+    }
+
+    public func updateUIView(_ textField: UITextField, context: Context) {
+        if text != 0 {
+            let newText = numberFormatter.string(for: text) ?? ""
+            if textField.text != newText {
+                textField.text = newText
+            }
+        }
+
+        textField.textColor = textColor
+        textField.textAlignment = textAlignment
+        textField.keyboardType = keyboardType
+        textField.autocapitalizationType = autocapitalizationType
+        textField.autocorrectionType = autocorrectionType
+
+        if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
+            if textField.window != nil, textField.becomeFirstResponder() {
+                context.coordinator.didBecomeFirstResponder = true
+            }
+        } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
+            context.coordinator.didBecomeFirstResponder = false
+        }
+    }
+
+    public func makeCoordinator() -> Coordinator {
+        Coordinator(self, maxLength: maxLength)
+    }
+
+    public final class Coordinator: NSObject {
+        var parent: TextFieldWithToolBar
+        var textField: UITextField?
+        let maxLength: Int?
+        var didBecomeFirstResponder = false
+        let decimalFormatter: NumberFormatter
+
+        init(_ parent: TextFieldWithToolBar, maxLength: Int?) {
+            self.parent = parent
+            self.maxLength = maxLength
+            decimalFormatter = NumberFormatter()
+            decimalFormatter.locale = Locale.current
+            decimalFormatter.numberStyle = .decimal
+        }
+
+        @objc fileprivate func clearText() {
+            parent.text = 0
+            textField?.text = ""
+        }
+
+        @objc fileprivate func editingDidBegin(_ textField: UITextField) {
+            DispatchQueue.main.async {
+                textField.moveCursorToEnd()
+            }
+        }
+    }
+}
+
+extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
+    public func textField(
+        _ textField: UITextField,
+        shouldChangeCharactersIn range: NSRange,
+        replacementString string: String
+    ) -> Bool {
+        // Check if the input is a number or the decimal separator
+        let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
+        let isDecimalSeparator = (string == decimalFormatter.decimalSeparator && textField.text?.contains(string) == false)
+
+        // Only proceed if the input is a valid number or decimal separator
+        if isNumber || isDecimalSeparator && parent.allowDecimalSeparator,
+           let currentText = textField.text as NSString?
+        {
+            // Get the proposed new text
+            let proposedText = currentText.replacingCharacters(in: range, with: string)
+
+            // Try to convert proposed text to number
+            let number = parent.numberFormatter.number(from: proposedText) ?? decimalFormatter.number(from: proposedText)
+
+            // Update the binding value if conversion is successful
+            if let number = number {
+                parent.text = number.decimalValue
+            } else {
+                parent.text = 0
+            }
+        }
+
+        // Allow the change if it's a valid number or decimal separator
+        return isNumber || isDecimalSeparator && parent.allowDecimalSeparator
+    }
+
+    public func textFieldDidBeginEditing(_: UITextField) {
+        parent.textFieldDidBeginEditing?()
+    }
+}
+
+extension UITextField {
+    func moveCursorToEnd() {
+        dispatchPrecondition(condition: .onQueue(.main))
+        let newPosition = endOfDocument
+        selectedTextRange = textRange(from: newPosition, to: newPosition)
+    }
+}
+
+extension UIApplication {
+    func endEditing() {
+        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
+    }
+}
+
+public struct TextFieldWithToolBarString: UIViewRepresentable {
+    @Binding var text: String
+    var placeholder: String
+    var textAlignment: NSTextAlignment = .right
+    var keyboardType: UIKeyboardType = .default
+    var autocapitalizationType: UITextAutocapitalizationType = .none
+    var autocorrectionType: UITextAutocorrectionType = .no
+    var shouldBecomeFirstResponder: Bool = false
+    var maxLength: Int? = nil
+    var isDismissible: Bool = true
+
+    public func makeUIView(context: Context) -> UITextField {
+        let textField = UITextField()
+        context.coordinator.textField = textField
+        textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
+        textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
+        textField.delegate = context.coordinator
+        textField.text = text
+        textField.placeholder = placeholder
+        textField.textAlignment = textAlignment
+        textField.keyboardType = keyboardType
+        textField.autocapitalizationType = autocapitalizationType
+        textField.autocorrectionType = autocorrectionType
+        textField.adjustsFontSizeToFitWidth = true
+        return textField
+    }
+
+    private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
+        let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
+        let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
+        let doneButton = UIBarButtonItem(
+            image: UIImage(systemName: "keyboard.chevron.compact.down"),
+            style: .done,
+            target: textField,
+            action: #selector(UITextField.resignFirstResponder)
+        )
+        let clearButton = UIBarButtonItem(
+            image: UIImage(systemName: "trash"),
+            style: .plain,
+            target: context.coordinator,
+            action: #selector(Coordinator.clearText)
+        )
+
+        toolbar.items = [clearButton, flexibleSpace, doneButton]
+        toolbar.sizeToFit()
+        return toolbar
+    }
+
+    public func updateUIView(_ textField: UITextField, context: Context) {
+        if textField.text != text {
+            textField.text = text
+        }
+
+        textField.textAlignment = textAlignment
+        textField.keyboardType = keyboardType
+        textField.autocapitalizationType = autocapitalizationType
+        textField.autocorrectionType = autocorrectionType
+
+        if shouldBecomeFirstResponder, !context.coordinator.didBecomeFirstResponder {
+            if textField.window != nil, textField.becomeFirstResponder() {
+                context.coordinator.didBecomeFirstResponder = true
+            }
+        } else if !shouldBecomeFirstResponder, context.coordinator.didBecomeFirstResponder {
+            context.coordinator.didBecomeFirstResponder = false
+        }
+    }
+
+    public func makeCoordinator() -> Coordinator {
+        Coordinator(self, maxLength: maxLength)
+    }
+
+    public final class Coordinator: NSObject {
+        var parent: TextFieldWithToolBarString
+        var textField: UITextField?
+        let maxLength: Int?
+        var didBecomeFirstResponder = false
+
+        init(_ parent: TextFieldWithToolBarString, maxLength: Int?) {
+            self.parent = parent
+            self.maxLength = maxLength
+        }
+
+        @objc fileprivate func clearText() {
+            parent.text = ""
+            textField?.text = ""
+        }
+
+        @objc fileprivate func editingDidBegin(_ textField: UITextField) {
+            DispatchQueue.main.async {
+                textField.moveCursorToEnd()
+            }
+        }
+    }
+}
+
+extension TextFieldWithToolBarString.Coordinator: UITextFieldDelegate {
+    public func textField(
+        _ textField: UITextField,
+        shouldChangeCharactersIn range: NSRange,
+        replacementString string: String
+    ) -> Bool {
+        if let maxLength = parent.maxLength {
+            // Get the current text, including the proposed change
+            let currentText = textField.text ?? ""
+            let newLength = currentText.count + string.count - range.length
+            if newLength > maxLength {
+                return false
+            }
+        }
+
+        DispatchQueue.main.async {
+            if let textFieldText = textField.text as NSString? {
+                let newText = textFieldText.replacingCharacters(in: range, with: string)
+                self.parent.text = newText
+            }
+        }
+
+        return true
+    }
+}

+ 18 - 16
Gemfile.lock

@@ -5,22 +5,22 @@ GEM
       base64
       nkf
       rexml
-    addressable (2.8.6)
-      public_suffix (>= 2.0.2, < 6.0)
+    addressable (2.8.7)
+      public_suffix (>= 2.0.2, < 7.0)
     artifactory (3.0.17)
     atomos (0.1.3)
     aws-eventstream (1.3.0)
-    aws-partitions (1.921.0)
-    aws-sdk-core (3.193.0)
+    aws-partitions (1.949.0)
+    aws-sdk-core (3.200.0)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-partitions (~> 1, >= 1.651.0)
       aws-sigv4 (~> 1.8)
       jmespath (~> 1, >= 1.6.1)
-    aws-sdk-kms (1.80.0)
-      aws-sdk-core (~> 3, >= 3.193.0)
+    aws-sdk-kms (1.87.0)
+      aws-sdk-core (~> 3, >= 3.199.0)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.148.0)
-      aws-sdk-core (~> 3, >= 3.193.0)
+    aws-sdk-s3 (1.155.0)
+      aws-sdk-core (~> 3, >= 3.199.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.8)
     aws-sigv4 (1.8.0)
@@ -69,7 +69,7 @@ GEM
     faraday_middleware (1.2.0)
       faraday (~> 1.0)
     fastimage (2.3.1)
-    fastlane (2.220.0)
+    fastlane (2.221.1)
       CFPropertyList (>= 2.3, < 4.0.0)
       addressable (>= 2.8, < 3.0.0)
       artifactory (~> 3.0)
@@ -148,31 +148,32 @@ GEM
       os (>= 0.9, < 2.0)
       signet (>= 0.16, < 2.a)
     highline (2.0.3)
-    http-cookie (1.0.5)
+    http-cookie (1.0.6)
       domain_name (~> 0.5)
     httpclient (2.8.3)
     jmespath (1.6.2)
     json (2.7.2)
-    jwt (2.8.1)
+    jwt (2.8.2)
       base64
-    mini_magick (4.12.0)
+    mini_magick (4.13.1)
     mini_mime (1.1.5)
     multi_json (1.15.0)
-    multipart-post (2.4.0)
+    multipart-post (2.4.1)
     nanaimo (0.3.0)
     naturally (2.2.1)
     nkf (0.2.0)
     optparse (0.5.0)
     os (1.1.4)
     plist (3.7.1)
-    public_suffix (5.0.5)
+    public_suffix (5.1.1)
     rake (13.2.1)
     representable (3.2.0)
       declarative (< 0.1.0)
       trailblazer-option (>= 0.1.1, < 0.2.0)
       uber (< 0.2.0)
     retriable (3.1.2)
-    rexml (3.2.6)
+    rexml (3.2.9)
+      strscan
     rouge (2.0.7)
     ruby2_keywords (0.0.5)
     rubyzip (2.3.2)
@@ -185,6 +186,7 @@ GEM
     simctl (1.6.10)
       CFPropertyList
       naturally
+    strscan (3.1.0)
     terminal-notifier (2.0.0)
     terminal-table (3.0.2)
       unicode-display_width (>= 1.1.1, < 3)
@@ -222,4 +224,4 @@ DEPENDENCIES
   fastlane
 
 BUNDLED WITH
-   2.4.19
+   2.4.19

+ 218 - 96
LiveActivity/LiveActivity.swift

@@ -45,19 +45,22 @@ struct LiveActivity: Widget {
         }
     }
 
-    @ViewBuilder func mealLabel(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+    @ViewBuilder func mealLabel(
+        context _: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
         VStack(alignment: .leading, spacing: 1, content: {
             HStack {
                 Text("COB: ").font(.caption)
                 Text(
-                    (carbsFormatter.string(from: context.state.cob as NSNumber) ?? "--") +
+                    (carbsFormatter.string(from: additionalState.cob as NSNumber) ?? "--") +
                         NSLocalizedString(" g", comment: "grams of carbs")
                 ).font(.caption).fontWeight(.bold)
             }
             HStack {
                 Text("IOB: ").font(.caption)
                 Text(
-                    (bolusFormatter.string(from: context.state.iob as NSNumber) ?? "--") +
+                    (bolusFormatter.string(from: additionalState.iob as NSNumber) ?? "--") +
                         NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)")
                 ).font(.caption).fontWeight(.bold)
             }
@@ -74,6 +77,11 @@ struct LiveActivity: Widget {
         }
     }
 
+    private func expiredLabel() -> some View {
+        Text("Live Activity Expired. Open Trio to Refresh")
+            .minimumScaleFactor(0.01)
+    }
+
     private func updatedLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
         let text = Text("Updated: \(dateFormatter.string(from: context.state.date))")
         if context.isStale {
@@ -106,7 +114,7 @@ struct LiveActivity: Widget {
         var directionText: String?
         var warnColor: Color?
         if let direction = context.state.direction {
-            if size == .compact {
+            if size == .compact || size == .minimal {
                 directionText = String(direction[direction.startIndex ... direction.startIndex])
 
                 if direction.count > 1 {
@@ -148,33 +156,36 @@ struct LiveActivity: Widget {
             }
         }
         .foregroundStyle(
-            context.state.lockScreenView == "Simple" ? (context.isStale ? Color.primary.opacity(0.5) : Color.primary) :
+            context.state.detailedViewState == nil ? (context.isStale ? Color.primary.opacity(0.5) : Color.primary) :
                 (context.isStale ? Color.white.opacity(0.5) : Color.white)
         )
 
         return (stack, characters)
     }
 
-    @ViewBuilder func chart(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+    @ViewBuilder func chart(
+        context: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
         if context.isStale {
             Text("No data available")
         } else {
             Chart {
-                ForEach(context.state.chart.indices, id: \.self) { index in
-                    let currentValue = context.state.chart[index]
-                    if currentValue > context.state.highGlucose {
+                ForEach(additionalState.chart.indices, id: \.self) { index in
+                    let currentValue = additionalState.chart[index]
+                    if currentValue > additionalState.highGlucose {
                         PointMark(
-                            x: .value("Time", context.state.chartDate[index] ?? Date()),
+                            x: .value("Time", additionalState.chartDate[index] ?? Date()),
                             y: .value("Value", currentValue)
                         ).foregroundStyle(Color.orange.gradient).symbolSize(12)
-                    } else if currentValue < context.state.lowGlucose {
+                    } else if currentValue < additionalState.lowGlucose {
                         PointMark(
-                            x: .value("Time", context.state.chartDate[index] ?? Date()),
+                            x: .value("Time", additionalState.chartDate[index] ?? Date()),
                             y: .value("Value", currentValue)
                         ).foregroundStyle(Color.red.gradient).symbolSize(12)
                     } else {
                         PointMark(
-                            x: .value("Time", context.state.chartDate[index] ?? Date()),
+                            x: .value("Time", additionalState.chartDate[index] ?? Date()),
                             y: .value("Value", currentValue)
                         ).foregroundStyle(Color.green.gradient).symbolSize(12)
                     }
@@ -198,101 +209,212 @@ struct LiveActivity: Widget {
         }
     }
 
-    var body: some WidgetConfiguration {
-        ActivityConfiguration(for: LiveActivityAttributes.self) { context in
-            // Lock screen/banner UI goes here
-            if context.state.lockScreenView == "Simple" {
-                HStack(spacing: 3) {
-                    bgAndTrend(context: context, size: .expanded).0.font(.title)
+    @ViewBuilder func content(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+        // Lock screen/banner UI goes here
+        if let detailedViewState = context.state.detailedViewState {
+            HStack(spacing: 2) {
+                VStack {
+                    chart(context: context, additionalState: detailedViewState).frame(width: UIScreen.main.bounds.width / 1.8)
+                }.padding(.all, 15)
+                Divider().foregroundStyle(Color.white)
+                VStack(alignment: .center) {
                     Spacer()
-                    VStack(alignment: .trailing, spacing: 5) {
-                        changeLabel(context: context).font(.title3)
-                        updatedLabel(context: context).font(.caption).foregroundStyle(.primary.opacity(0.7))
-                    }
+                    ZStack {
+                        VStack {
+                            bgAndTrend(context: context, size: .expanded).0.font(.largeTitle)
+                            changeLabel(context: context).font(.callout)
+                        }.frame(width: 130, height: 130)
+                    }.scaleEffect(0.85).offset(y: 30)
+                    mealLabel(context: context, additionalState: detailedViewState).padding(.bottom, 8)
+                    updatedLabel(context: context).font(.caption).padding(.bottom, 70)
                 }
-                .privacySensitive()
-                .padding(.all, 15)
-                // Semantic BackgroundStyle and Color values work here. They adapt to the given interface style (light mode, dark mode)
-                // Semantic UIColors do NOT (as of iOS 17.1.1). Like UIColor.systemBackgroundColor (it does not adapt to changes of the interface style)
-                // The colorScheme environment varaible that is usually used to detect dark mode does NOT work here (it reports false values)
-                .foregroundStyle(Color.primary)
-                .background(BackgroundStyle.background.opacity(0.4))
-                .activityBackgroundTint(Color.clear)
-            } else {
-                HStack(spacing: 2) {
-                    VStack {
-                        chart(context: context).frame(width: UIScreen.main.bounds.width / 1.8)
-                    }.padding(.all, 15)
-                    Divider().foregroundStyle(Color.white)
-                    VStack(alignment: .center) {
+            }
+            .privacySensitive()
+            .imageScale(.small)
+            .background(Color.white.opacity(0.2))
+            .foregroundColor(Color.white)
+            .activityBackgroundTint(Color.black.opacity(0.7))
+            .activitySystemActionForegroundColor(Color.white)
+        } else {
+            Group {
+                if context.state.isInitialState {
+                    // add vertical and horizontal spacers around the label to ensure that the live activity view gets filled completely
+                    HStack {
+                        Spacer()
+                        VStack {
+                            Spacer()
+                            expiredLabel()
+                            Spacer()
+                        }
                         Spacer()
-                        ZStack {
-                            VStack {
-                                bgAndTrend(context: context, size: .expanded).0.font(.largeTitle)
-                                changeLabel(context: context).font(.callout)
-                            }.frame(width: 130, height: 130)
-                        }.scaleEffect(0.85).offset(y: 30)
-                        mealLabel(context: context).padding(.bottom, 8)
-                        updatedLabel(context: context).font(.caption).padding(.bottom, 70)
                     }
-                }
-                .privacySensitive()
-                .imageScale(.small)
-                .background(Color.white.opacity(0.2))
-                .foregroundColor(Color.white)
-                .activityBackgroundTint(Color.black.opacity(0.7))
-                .activitySystemActionForegroundColor(Color.white)
-            }
-        } dynamicIsland: { context in
-            DynamicIsland {
-                // Expanded UI goes here.  Compose the expanded UI through
-                // various regions, like leading/trailing/center/bottom
-                DynamicIslandExpandedRegion(.leading) {
-                    bgAndTrend(context: context, size: .expanded).0.font(.title2).padding(.leading, 5)
-                }
-                DynamicIslandExpandedRegion(.trailing) {
-                    changeLabel(context: context).font(.title2).padding(.trailing, 5)
-                }
-                DynamicIslandExpandedRegion(.bottom) {
-                    if context.state.lockScreenView == "Simple" {
-                        Group {
-                            updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
+                } else {
+                    HStack(spacing: 3) {
+                        bgAndTrend(context: context, size: .expanded).0.font(.title)
+                        Spacer()
+                        VStack(alignment: .trailing, spacing: 5) {
+                            changeLabel(context: context).font(.title3)
+                            updatedLabel(context: context).font(.caption).foregroundStyle(.primary.opacity(0.7))
                         }
-                        .frame(
-                            maxHeight: .infinity,
-                            alignment: .bottom
-                        )
-                    } else {
-                        chart(context: context)
                     }
                 }
-                DynamicIslandExpandedRegion(.center) {
-                    if context.state.lockScreenView == "Detailed" {
+            }
+            .privacySensitive()
+            .padding(.all, 15)
+            // Semantic BackgroundStyle and Color values work here. They adapt to the given interface style (light mode, dark mode)
+            // Semantic UIColors do NOT (as of iOS 17.1.1). Like UIColor.systemBackgroundColor (it does not adapt to changes of the interface style)
+            // The colorScheme environment varaible that is usually used to detect dark mode does NOT work here (it reports false values)
+            .foregroundStyle(Color.primary)
+            .background(BackgroundStyle.background.opacity(0.4))
+            .activityBackgroundTint(Color.clear)
+        }
+    }
+
+    func dynamicIsland(context: ActivityViewContext<LiveActivityAttributes>) -> DynamicIsland {
+        DynamicIsland {
+            // Expanded UI goes here.  Compose the expanded UI through
+            // various regions, like leading/trailing/center/bottom
+            DynamicIslandExpandedRegion(.leading) {
+                bgAndTrend(context: context, size: .expanded).0.font(.title2).padding(.leading, 5)
+            }
+            DynamicIslandExpandedRegion(.trailing) {
+                changeLabel(context: context).font(.title2).padding(.trailing, 5)
+            }
+            DynamicIslandExpandedRegion(.bottom) {
+                if context.state.isInitialState {
+                    expiredLabel()
+                } else if let detailedViewState = context.state.detailedViewState {
+                    chart(context: context, additionalState: detailedViewState)
+                } else {
+                    Group {
                         updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
                     }
+                    .frame(
+                        maxHeight: .infinity,
+                        alignment: .bottom
+                    )
                 }
-            } compactLeading: {
-                bgAndTrend(context: context, size: .compact).0.padding(.leading, 4)
-            } compactTrailing: {
-                changeLabel(context: context).padding(.trailing, 4)
-            } minimal: {
-                let (_label, characterCount) = bgAndTrend(context: context, size: .minimal)
-
-                let label = _label.padding(.leading, 7).padding(.trailing, 3)
-
-                if characterCount < 4 {
-                    label
-                } else if characterCount < 5 {
-                    label.fontWidth(.condensed)
-                } else {
-                    label.fontWidth(.compressed)
+            }
+            DynamicIslandExpandedRegion(.center) {
+                if context.state.detailedViewState != nil {
+                    updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
                 }
             }
-            .widgetURL(URL(string: "Trio://"))
-            .keylineTint(Color.purple)
-            .contentMargins(.horizontal, 0, for: .minimal)
-            .contentMargins(.trailing, 0, for: .compactLeading)
-            .contentMargins(.leading, 0, for: .compactTrailing)
+        } compactLeading: {
+            bgAndTrend(context: context, size: .compact).0.padding(.leading, 4)
+        } compactTrailing: {
+            changeLabel(context: context).padding(.trailing, 4)
+        } minimal: {
+            let (_label, characterCount) = bgAndTrend(context: context, size: .minimal)
+
+            let label = _label.padding(.leading, 7).padding(.trailing, 3)
+
+            if characterCount < 4 {
+                label
+            } else if characterCount < 5 {
+                label.fontWidth(.condensed)
+            } else {
+                label.fontWidth(.compressed)
+            }
         }
+        .widgetURL(URL(string: "Trio://"))
+        .keylineTint(Color.purple)
+        .contentMargins(.horizontal, 0, for: .minimal)
+        .contentMargins(.trailing, 0, for: .compactLeading)
+        .contentMargins(.leading, 0, for: .compactTrailing)
     }
+
+    var body: some WidgetConfiguration {
+        ActivityConfiguration(for: LiveActivityAttributes.self, content: self.content, dynamicIsland: self.dynamicIsland)
+    }
+}
+
+private extension LiveActivityAttributes {
+    static var preview: LiveActivityAttributes {
+        LiveActivityAttributes(startDate: Date())
+    }
+}
+
+private extension LiveActivityAttributes.ContentState {
+    // 0 is the widest digit. Use this to get an upper bound on text width.
+
+    // Use mmol/l notation with decimal point as well for the same reason, it uses up to 4 characters, while mg/dl uses up to 3
+    static var testWide: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00.0",
+            direction: "→",
+            change: "+0.0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    static var testVeryWide: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00.0",
+            direction: "↑↑",
+            change: "+0.0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    static var testSuperWide: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00.0",
+            direction: "↑↑↑",
+            change: "+0.0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    // 2 characters for BG, 1 character for change is the minimum that will be shown
+    static var testNarrow: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00",
+            direction: "↑",
+            change: "+0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    static var testMedium: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "000",
+            direction: "↗︎",
+            change: "+00",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    static var testExpired: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "--",
+            direction: nil,
+            change: "--",
+            date: Date().addingTimeInterval(-60 * 60),
+            detailedViewState: nil,
+            isInitialState: true
+        )
+    }
+}
+
+@available(iOS 17.0, iOSApplicationExtension 17.0, *)
+#Preview("Notification", as: .content, using: LiveActivityAttributes.preview) {
+    LiveActivity()
+} contentStates: {
+    LiveActivityAttributes.ContentState.testSuperWide
+    LiveActivityAttributes.ContentState.testVeryWide
+    LiveActivityAttributes.ContentState.testWide
+    LiveActivityAttributes.ContentState.testMedium
+    LiveActivityAttributes.ContentState.testNarrow
+    LiveActivityAttributes.ContentState.testExpired
 }

+ 0 - 66
LiveActivity/WidgetBobble 2.swift

@@ -1,66 +0,0 @@
-import SwiftUI
-
-struct WidgetBobble: View {
-    @Environment(\.colorScheme) var colorScheme
-
-    let gradient: AngularGradient
-    let color: Color
-
-    var body: some View {
-        HStack(alignment: .center) {
-            ZStack {
-                Group {
-                    CircleShapeWidget(gradient: gradient)
-                    TriangleShapeWidget(color: color)
-                }
-                CircleShapeWidget(gradient: gradient)
-            }
-        }
-    }
-}
-
-struct CircleShapeWidget: View {
-    @Environment(\.colorScheme) var colorScheme
-
-    let gradient: AngularGradient
-
-    var body: some View {
-//        let colorBackground: Color = colorScheme == .dark ? Color(
-//            red: 0.05490196078,
-//            green: 0.05490196078,
-//            blue: 0.05490196078
-//        ) : .white
-
-        Circle()
-            .stroke(gradient, lineWidth: 10)
-            .background(Circle().fill(.clear))
-            .frame(width: 130, height: 130)
-    }
-}
-
-struct TriangleShapeWidget: View {
-    let color: Color
-
-    var body: some View {
-        TriangleWidget()
-            .fill(color)
-            .frame(width: 35, height: 35)
-            .rotationEffect(.degrees(90))
-            .offset(x: 78)
-    }
-}
-
-struct TriangleWidget: Shape {
-    func path(in rect: CGRect) -> Path {
-        var path = Path()
-
-        let cornerRadius: CGFloat = 2
-
-        path.move(to: CGPoint(x: rect.midX, y: rect.minY))
-        path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - cornerRadius))
-        path.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.maxY - cornerRadius), control: CGPoint(x: rect.midX, y: rect.maxY))
-        path.closeSubpath()
-
-        return path
-    }
-}

+ 1 - 1
MinimedKit

@@ -1 +1 @@
-Subproject commit 4a6df31192cbbddf62ed36ad78b1ca29649bd851
+Subproject commit f11abde5e2eea2cbf7ac80f3f4bc4bc6e7f6de56

+ 4 - 6
fastlane/Fastfile

@@ -184,24 +184,22 @@ platform :ios do
       }
     end
 
-    configure_bundle_id("FreeAPS", "#{BUNDLE_ID}", [
+    configure_bundle_id("Trio", "#{BUNDLE_ID}", [
       Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS,
       Spaceship::ConnectAPI::BundleIdCapability::Type::HEALTHKIT,
       Spaceship::ConnectAPI::BundleIdCapability::Type::NFC_TAG_READING
     ])
 
-    configure_bundle_id("FreeAPSWatch WatchKit Extension", "#{BUNDLE_ID}.watchkitapp.watchkitextension", [
+    configure_bundle_id("Trio WatchKit Extension", "#{BUNDLE_ID}.watchkitapp.watchkitextension", [
       Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS,
       Spaceship::ConnectAPI::BundleIdCapability::Type::HEALTHKIT
     ])
     
-    configure_bundle_id("FreeAPSWatch", "#{BUNDLE_ID}.watchkitapp", [
+    configure_bundle_id("Trio Watch", "#{BUNDLE_ID}.watchkitapp", [
       Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS
     ])
 
-    configure_bundle_id("LiveActivityExtension", "#{BUNDLE_ID}.LiveActivity", [
-      Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS
-    ])
+    configure_bundle_id("Trio LiveActivity", "#{BUNDLE_ID}.LiveActivity", [])
     
   end
 

+ 139 - 23
fastlane/testflight.md

@@ -2,7 +2,7 @@
 
 These instructions allow you to build Trio without having access to a Mac.
 
-* You can install Trio on phones via TestFlight that are not connected to your computer
+* You can install Trio on phones using TestFlight that are not connected to your computer
 * You can send builds and updates to those you care for
 * You can install Trio on your phone using only the TestFlight app if a phone was lost or the app is accidentally deleted
 * You do not need to worry about specific Xcode/Mac versions for a given iOS
@@ -16,17 +16,20 @@ These instructions allow you to build Trio without having access to a Mac.
 > 
 > It also creates an alive branch, if you don't already have one. See [Why do I have an alive branch?](#why-do-i-have-an-alive-branch).
 >
+> The [**Optional**](#optional) section provides instructions to modify the default behavior if desired. >
 
 ## Introduction
 
 The setup steps are somewhat involved, but nearly all are one time steps. Subsequent builds are trivial. Your app must be updated once every 90 days, but it's a simple click to make a new build and can be done from anywhere.
 
-Note that TestFlight requires apple id accounts 13 years or older. This can be circumvented by logging into Media & Purchase on the child's phone with an adult's account. More details on this can be found in [LoopDocs](https://loopkit.github.io/loopdocs/gh-actions/gh-deploy/#install-testflight-loop-for-child).
+Note that installing with TestFlight requires the Apple ID account holder for the phone be 13 years or older (age varies with country). This can be circumvented by logging into Media & Purchase on the child's phone with an adult's account. More details on this can be found in [LoopDocs](https://loopkit.github.io/loopdocs/gh-actions/gh-deploy/#install-testflight-loop-for-child).
 
 This method for building without a Mac was ported from Loop. If you have used this method for Loop or one of the other DIY apps (Loop Caregiver, Loop Follow, Xdrip4iOS), some of the steps can be re-used and the full set of instructions does not need to be repeated. This will be mentioned in relevant sections below.
 
 There are more detailed instructions in LoopDocs for doing Browser Builds of Loop and other apps, including troubleshooting and build errors. Please refer to [LoopDocs](https://loopkit.github.io/loopdocs/gh-actions/gh-other-apps/) for more details.
 
+If you build multiple apps, you may want to use a free *GitHub* organization. Please refer to [LoopDocs: Use a *GitHub* Organization Account](https://loopkit.github.io/loopdocs/gh-actions/gh-other-apps/#use-a-github-organization-account).
+
 ## Prerequisites
 
 * A [github account](https://github.com/signup). The free level comes with plenty of storage and free compute time to build Trio, multiple times a day, if you wanted to.
@@ -43,6 +46,8 @@ You require 6 Secrets (alphanumeric items) to use the GitHub build method and if
 * Be sure to save the 6 Secrets in a text file using a text editor
     - Do **NOT** use a smart editor, which might auto-correct and change case, because these Secrets are case sensitive
 
+Refer to [LoopDocs: Make a Secrets Reference File](https://loopkit.github.io/loopdocs/gh-actions/gh-first-time/#make-a-secrets-reference-file) for a handy template to use when saving your Secrets.
+
 ## Generate App Store Connect API Key
 
 This step is common for all GitHub Browser Builds; do this step only once. You will be saving 4 Secrets from your Apple Account in this step.
@@ -56,7 +61,7 @@ This step is common for all GitHub Browser Builds; do this step only once. You w
 
 ## Create GitHub Personal Access Token
 
-If you have previously built another app using the "browser build" method, you can can re-use your previous personal access token (`GH_PAT`) and skip this step.
+If you have previously built another app using the "browser build" method, you use the same personal access token (`GH_PAT`), so skip this step.
 
 Log into your GitHub account to create a personal access token; this is one of two GitHub secrets needed for your build.
 
@@ -71,18 +76,14 @@ Log into your GitHub account to create a personal access token; this is one of t
 
 This is the second one of two GitHub secrets needed for your build.
 
-The first time you build with the GitHub Browser Build method for any DIY app, you will make up a password and record it as `MATCH_PASSWORD`. Note, if you later lose `MATCH_PASSWORD`, you will need to delete and make a new Match-Secrets repository (next step).
-
-## Setup GitHub Match-Secrets Repository
+The first time you build with the GitHub Browser Build method for any DIY app, you will make up a password and record it as `MATCH_PASSWORD`. You use the same password for all DIY apps. Note, if you later lose `MATCH_PASSWORD`, you will need to delete your Match-Secrets repository (automatically created), and go through the GitHub actions again.
 
-The creation of the Match-Secrets repository is a common step for all GitHub Browser Builds; do this step only once. You must be logged into your GitHub account.
+## GitHub Match-Secrets Repository
 
-1. Create a [new empty repository](https://github.com/new) titled `Match-Secrets`. It should be private.
-
-Once created, you will not take any direct actions with this repository; it needs to be there for the GitHub to use as you progress through the steps.
+> A private Match-Secrets repository is automatically created under your GitHub username the first time you run a GitHub Action. Because it is a private repository - only you can see it. You will not take any direct actions with this repository; it needs to be there for GitHub to use as you progress through the steps.
 
 ## Setup Github Trio repository
-1. Fork https://github.com/nightscout/Trio into your account. If you already have a fork of Trio in GitHub, you can't make another one. You can continue to work with your existing fork, or delete that from GitHub and then and fork https://github.com/nightscout/Trio.
+1. Fork https://github.com/nightscout/Trio into your account. If you already have a fork of Trio in GitHub, you can't make another one. You can continue to work with your existing fork, or delete that from GitHub and then fork https://github.com/nightscout/Trio.
 1. In the forked Trio repo, go to Settings -> Secrets and variables -> Actions.
 1. For each of the following secrets, tap on "New repository secret", then add the name of the secret, along with the value you recorded for it:
     * `TEAMID`
@@ -94,7 +95,7 @@ Once created, you will not take any direct actions with this repository; it need
 
 ## Validate repository secrets
 
-This step validates most of your six Secrets and provides error messages if it detects an issue with one or more.
+This step validates most of your six Secrets and provides error messages if it detects an issue with one or more. In addition, if you do not have a private Match-Secrets repository it creates one for you.
 
 1. Click on the "Actions" tab of your Trio repository and enable workflows if needed
 1. On the left side, select "1. Validate Secrets".
@@ -102,6 +103,8 @@ This step validates most of your six Secrets and provides error messages if it d
 1. Wait, and within a minute or two you should see a green checkmark indicating the workflow succeeded.
 1. The workflow will check if the required secrets are added and that they are correctly formatted. If errors are detected, please check the run log for details.
 
+> There can be a delay after you start a workflow before the screen changes. Refresh your browser to see if it started. And if it seems to take a long time to finish - refresh your browser to see if it is done.
+
 ## Add Identifiers for Trio App
 
 1. Click on the "Actions" tab of your Trio repository.
@@ -111,32 +114,78 @@ This step validates most of your six Secrets and provides error messages if it d
 
 ## Create App Group
 
-If you have already built Trio via Xcode using this Apple ID, you can skip on to [Create Trio App in App Store Connect](#create-trio-app-in-app-store-connect).
-_Please note that in default builds of Trio, the app group is actually identical to the one used with Loop, so please enter these details exactly as described below. This is to ease the setup of apps such as Xdrip4iOS. It may require some caution if transfering between Trio and Loop._
+If you previously built Trio using Mac with Xcode with this Apple ID, skip ahead to [Optional: App Group Description Modification](#optional-app-group-description-modification).
+
+_Please note that Trio uses the same app group as Loop. This enables other apps such as Xdrip4iOS to share data with Trio. It may require some caution if transfering between Trio and Loop._
 
 1. Go to [Register an App Group](https://developer.apple.com/account/resources/identifiers/applicationGroup/add/) on the apple developer site.
 1. For Description, use "Loop App Group".
 1. For Identifier, enter "group.com.TEAMID.loopkit.LoopGroup", substituting your team id for `TEAMID`.
+    * If you are told that this group already exists, skip ahead to [Optional: App Group Description Modification](#optional-app-group-description-modification)
 1. Click "Continue" and then "Register".
 
+### Optional: App Group Description Modification
+
+> This step is not required, but if you previously built using a Mac with Xcode, it is a good idea to update the **NAME** associated with the **IDENTIFIER** for the Loop App Group. Notice in the table below that the XCode version of the **NAME** is the same as the **IDENTIFIER** but with the `.` replaced with a space.
+
+_Referring to the link and table below, tap on the **IDENTIFIER** for the `Loop App Group`, edit the Description to match the **NAME**, then Save the change._
+
+* [App Group List](https://developer.apple.com/account/resources/identifiers/list/applicationGroup)
+
+| NAME | XCode version | IDENTIFIER |
+|:--|:--|:--|
+| Loop App Group | group com TEAMID loopkit LoopGroup| group.com.TEAMID.loopkit.LoopGroup |
+
+## Bundle Identifiers
+
+Open this link in a separate browser window:
+
+* [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list) on the Apple developer site
+* You will select each of the Identifiers as instructed below, modify it if needed and then save it.
+
+### Optional: Identifier Description Modification
+
+> This step is not required, but if you previously built using a Mac with Xcode or during early Beta testing for Trio, it is a good idea to update the **NAME** associated with each **IDENTIFIER** to match the table below.
+
+_Referring to the table below, tap on each **IDENTIFIER** that has a different **NAME**, edit the Description to match the **NAME**, then Save the change for that identifier._
+
+#### Table of Identifiers
+
+* If you built previously using a Mac with Xcode, you may see the XCode version in your **NAME** column - it starts with XC and then the **IDENTIFIER** is appended where the `.` is replaced with a space, the example for Trio is shown in detail
+* If you built during early beta testing, you might not have `Trio` at the beginning of each **IDENTIFIER** and the full **NAME** may be slightly different
+
+| NAME | XCode version | IDENTIFIER |
+|:--|:--|:--|
+| Trio | XC org nightscout TEAMID trio | org.nightscout.TEAMID.trio |
+| Trio LiveActivity | - | org.nightscout.TEAMID.trio.LiveActivity |
+| Trio Watch | XC IDENTIFIER | org.nightscout.TEAMID.trio.watchkitapp |
+| Trio WatchKit Extension | XC IDENTIFIER | org.nightscout.TEAMID.trio.watchkitapp.watchkitextension |
+
 ## Add App Group to Bundle Identifiers
 
+> This step is required for first-time builders using GitHub Actions (Browser Build).
+
+> If you previously built using a Mac with Xcode you can skip ahead to [Create Trio App in App Store Connect](#create-trio-app-in-app-store-connect).
+
 1. Go to [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list) on the Apple developer site.
-1. For each of the following identifier names:
-    * FreeAPS
-    * FreeAPS watchkitapp
-    * FreeAPS watchkitapp watchkitextension
-1. Click on the identifier's name.
-1. On the "App Groups" capabilies, click on the "Configure" button.
-1. Select the "Loop App Group" _(yes, "Loop App Group" is correct)_
+1. Repeat this step for these three Identifier **NAMES** - refer to the [Table](#table-of-identifiers) above if your Names look different; if they do, see [Optional: Identifier Description Modification](#optional-identifier-description-modification)
+    * Trio
+    * Trio Watch
+    * Trio WatchKit Extension
+1. Click on the **IDENTIFIER** row.
+1. Scroll down to the "App Groups" capabilies row, click on the "Configure" (or "Edit") button.
+1. Select (or verify the selection for) the "Loop App Group" _(yes, "Loop App Group" is correct)_
 1. Click "Continue".
 1. Click "Save".
 1. Click "Confirm".
-1. Remember to do this for each of the identifiers above.
+1. Remember to do this for each of three identifiers listed under step 2.
+
+There is an additional identifier, but it does not need the App Group added to it:
+* Trio LiveActivity
 
 ## Create Trio App in App Store Connect
 
-If you have created a Trio app in App Store Connect before, you can skip this section as well.
+If you created a Trio app in App Store Connect before, skip ahead to [Create Building Certficates](#create-building-certficates).
 
 1. Go to the [apps list](https://appstoreconnect.apple.com/apps) on App Store Connect and click the blue "plus" icon to create a New App.
     * Select "iOS".
@@ -181,3 +230,70 @@ If a GitHub repository has no activity (no commits are made) in 60 days, then Gi
 The `build_trio.yml` file uses a special branch called `alive` and adds a dummy commit to the `alive` branch at regular intervals. This "trick" keeps the Actions enabled so the automated build works.
 
 The branch `alive` is created automatically for you. Do not delete or rename it! Do not modify `alive` yourself; it is not used for building the app.
+
+## OPTIONAL
+
+What if you don't want to allow automated updates of the repository or automatic builds?
+
+You can affect the default behavior:
+
+1. [`GH_PAT` `workflow` permission](#gh_pat-workflow-permission)
+1. [Modify scheduled building and synchronization](#modify-scheduled-building-and-synchronization)
+
+### `GH_PAT` `workflow` permission
+
+To enable the scheduled build and sync, the `GH_PAT` must hold the `workflow` permission scopes. This permission serves as the enabler for automatic and scheduled builds with browser build. To verify your token holds this permission, follow these steps.
+
+1. Go to your [FastLane Access Token](https://github.com/settings/tokens)
+2. It should say `repo`, `workflow` next to the `FastLane Access Token` link
+3. If it does not, click on the link to open the token detail view
+4. Click to check the `workflow` box. You will see that the checked boxes for the `repo` scope become disabled (change color to dark gray and are not clickable)
+5. Scroll all the way down to and click the green `Update token` button
+6. Your token now holds both required permissions
+
+If you choose not to have automatic building enabled, be sure the `GH_PAT` has `repo` scope or you won't be able to manually build.
+
+### Modify scheduled building and synchronization
+
+You can modify the automation by creating and using some variables.
+
+To configure the automated build more granularly involves creating up to two environment variables: `SCHEDULED_BUILD` and/or `SCHEDULED_SYNC`. See [How to configure a variable](#how-to-configure-a-variable). 
+
+Note that the weekly and monthly Build Trio actions will continue, but the actions are modified if one or more of these variables is set to false. **A successful Action Log will still appear, even if no automatic activity happens**.
+
+* If you want to manually decide when to update your repository to the latest commit, but you want the monthly builds and keep-alive to continue: set `SCHEDULED_SYNC` to false and either do not create `SCHEDULED_BUILD` or set it to true
+* If you want to only build when an update has been found: set `SCHEDULED_BUILD` to false and either do not create `SCHEDULED_SYNC` or set it to true
+    * **Warning**: if no updates to your default branch are detected within 90 days, your previous TestFlight build may expire requiring a manual build
+
+|`SCHEDULED_SYNC`|`SCHEDULED_BUILD`|Automatic Actions|
+|---|---|---|
+| `true` (or NA) | `true` (or NA) | keep-alive, weekly update check (auto update/build), monthly build with auto update|
+| `true` (or NA) | `false` | keep-alive, weekly update check with auto update, only builds if update detected|
+| `false` | `true` (or NA) | keep-alive, monthly build, no auto update |
+| `false` | `false` | no automatic activity, no keep-alive|
+
+### How to configure a variable
+
+1. Go to the "Settings" tab of your Trio repository.
+2. Click on `Secrets and Variables`.
+3. Click on `Actions`
+4. You will now see a page titled *Actions secrets and variables*. Click on the `Variables` tab
+5. To disable ONLY scheduled building, do the following:
+    - Click on the green `New repository variable` button (upper right)
+    - Type `SCHEDULED_BUILD` in the "Name" field
+    - Type `false` in the "Value" field
+    - Click the green `Add variable` button to save.
+7. To disable scheduled syncing, add a variable:
+    - Click on the green `New repository variable` button (upper right)
+    - - Type `SCHEDULED_SYNC` in the "Name" field
+    - Type `false` in the "Value" field
+    - Click the green `Add variable` button to save
+  
+Your build will run on the following conditions:
+- Default behaviour:
+    - Run weekly, every Wednesday at 08:00 UTC to check for changes; if there are changes, it will update your repository and build
+    - Run monthly, every first of the month at 06:00 UTC, if there are changes, it will update your repository; regardless of changes, it will build
+    - Each time the action runs, it makes a keep-alive commit to the `alive` branch if necessary
+- If you disable any automation (both variables set to `false`), no updates, keep-alive or building happens when Build Trio runs
+- If you disabled just scheduled synchronization (`SCHEDULED_SYNC` set to`false`), it will only run once a month, on the first of the month, no update will happen; keep-alive will run
+- If you disabled just scheduled build (`SCHEDULED_BUILD` set to`false`), it will run once weekly, every Wednesday, to check for changes; if there are changes, it will update and build; keep-alive will run