Przeglądaj źródła

Also make all the JS stuff async (#52)

* make apsManager and openAPS async....highly wip

* cleanup

* clean up

* test

* cleanup

* small fixes for LA, refactoring

* refactoring of async functions

* cleanup

* async save function for Determination

* address PR feedback (better error handling)

* make JS worker functions async

* use async storage retrieve func in autosense()

* textfield

* fix deletion of keyboard tabbar 'Clear' button, replace buttons with appropiate SF symbols

* fix for bolus UI not updating

* fix for a wrong context use in ISF State

* fix decimal textfield toolbar

---------

Co-authored-by: Marvin Polscheit <marvinpolscheit@mac-mini.speedport.ip>
polscm32 1 rok temu
rodzic
commit
03df910cb9
20 zmienionych plików z 651 dodań i 529 usunięć
  1. 45 0
      FreeAPS.xcworkspace/xcshareddata/swiftpm/Package.resolved
  2. 1 1
      FreeAPS/Sources/APS/APSManager.swift
  3. 347 292
      FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift
  4. 3 3
      FreeAPS/Sources/Modules/AddTempTarget/View/AddTempTargetRootView.swift
  5. 37 38
      FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift
  6. 16 4
      FreeAPS/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift
  7. 1 7
      FreeAPS/Sources/Modules/Calibrations/View/CalibrationsRootView.swift
  8. 4 6
      FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift
  9. 7 3
      FreeAPS/Sources/Modules/Dynamic/View/DynamicRootView.swift
  10. 8 4
      FreeAPS/Sources/Modules/FPUConfig/View/FPUConfigRootView.swift
  11. 2 1
      FreeAPS/Sources/Modules/ISFEditor/ISFEditorStateModel.swift
  12. 1 1
      FreeAPS/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift
  13. 1 1
      FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift
  14. 7 3
      FreeAPS/Sources/Modules/NotificationsConfig/View/NotificationsConfigRootView.swift
  15. 9 19
      FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift
  16. 4 4
      FreeAPS/Sources/Modules/PreferencesEditor/View/PreferencesEditorRootView.swift
  17. 4 4
      FreeAPS/Sources/Modules/PumpSettingsEditor/View/PumpSettingsEditorRootView.swift
  18. 2 2
      FreeAPS/Sources/Modules/StatConfig/View/StatConfigRootView.swift
  19. 24 0
      FreeAPS/Sources/Services/Storage/FileStorage.swift
  20. 128 136
      FreeAPS/Sources/Views/DecimalTextField.swift

+ 45 - 0
FreeAPS.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -20,6 +20,24 @@
         }
       },
       {
+        "package": "swift-algorithms",
+        "repositoryURL": "https://github.com/apple/swift-algorithms",
+        "state": {
+          "branch": null,
+          "revision": "2327673b0e9c7e90e6b1826376526ec3627210e4",
+          "version": "0.2.1"
+        }
+      },
+      {
+        "package": "swift-numerics",
+        "repositoryURL": "https://github.com/apple/swift-numerics",
+        "state": {
+          "branch": null,
+          "revision": "6583ac70c326c3ee080c1d42d9ca3361dca816cd",
+          "version": "0.1.0"
+        }
+      },
+      {
         "package": "SwiftCharts",
         "repositoryURL": "https://github.com/ivanschuetz/SwiftCharts",
         "state": {
@@ -27,6 +45,33 @@
           "revision": "c354c1945bb35a1f01b665b22474f6db28cba4a2",
           "version": null
         }
+      },
+      {
+        "package": "SwiftDate",
+        "repositoryURL": "https://github.com/malcommac/SwiftDate",
+        "state": {
+          "branch": null,
+          "revision": "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
+          "version": "6.3.1"
+        }
+      },
+      {
+        "package": "SwiftMessages",
+        "repositoryURL": "https://github.com/SwiftKickMobile/SwiftMessages",
+        "state": {
+          "branch": null,
+          "revision": "62e12e138fc3eedf88c7553dd5d98712aa119f40",
+          "version": "9.0.9"
+        }
+      },
+      {
+        "package": "Swinject",
+        "repositoryURL": "https://github.com/Swinject/Swinject",
+        "state": {
+          "branch": null,
+          "revision": "3125943807991bc271d366205d98713696d65a1f",
+          "version": "2.8.8"
+        }
       }
     ]
   },

+ 1 - 1
FreeAPS/Sources/APS/APSManager.swift

@@ -331,7 +331,7 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     func autosens() async throws -> Bool {
-        guard let autosens = storage.retrieve(OpenAPS.Settings.autosense, as: Autosens.self),
+        guard let autosens = await storage.retrieveAsync(OpenAPS.Settings.autosense, as: Autosens.self),
               (autosens.timestamp ?? .distantPast).addingTimeInterval(30.minutes.timeInterval) > Date()
         else {
             let result = try await openAPS.autosense()

+ 347 - 292
FreeAPS/Sources/APS/OpenAPS/OpenAPS.swift

@@ -189,72 +189,72 @@ final class OpenAPS {
         async let carbs = fetchAndProcessCarbs()
         async let glucose = fetchAndProcessGlucose()
         async let oref2 = oref2()
+        async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
+        async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile)
+        async let autosenseAsync = loadFileFromStorageAsync(name: Settings.autosense)
+        async let reservoirAsync = loadFileFromStorageAsync(name: Monitor.reservoir)
+        async let preferencesAsync = loadFileFromStorageAsync(name: Settings.preferences)
 
         // Await the results of asynchronous tasks
-        let pumpHistoryJSON = await parsePumpHistory(await pumpHistoryObjectIDs)
-        let carbsAsJSON = await carbs
-        let glucoseAsJSON = await glucose
-        let oref2_variables = await oref2
+        let (
+            pumpHistoryJSON,
+            carbsAsJSON,
+            glucoseAsJSON,
+            oref2_variables,
+            profile,
+            basalProfile,
+            autosens,
+            reservoir,
+            preferences
+        ) = await (
+            parsePumpHistory(await pumpHistoryObjectIDs),
+            carbs,
+            glucose,
+            oref2,
+            profileAsync,
+            basalAsync,
+            autosenseAsync,
+            reservoirAsync,
+            preferencesAsync
+        )
 
         // TODO: - Save and fetch profile/basalProfile in/from UserDefaults!
 
-        // Load files from Storage
-        let profile = loadFileFromStorage(name: Settings.profile)
-        let basalProfile = loadFileFromStorage(name: Settings.basalProfile)
-        let autosens = loadFileFromStorage(name: Settings.autosense)
-        let reservoir = loadFileFromStorage(name: Monitor.reservoir)
-        let preferences = loadFileFromStorage(name: Settings.preferences)
-
         // Meal
-        let meal: RawJSON = await withCheckedContinuation { continuation in
-            self.processQueue.async {
-                let result = self.meal(
-                    pumphistory: pumpHistoryJSON,
-                    profile: profile,
-                    basalProfile: basalProfile,
-                    clock: dateFormattedAsString,
-                    carbs: carbsAsJSON,
-                    glucose: glucoseAsJSON
-                )
-                continuation.resume(returning: result)
-            }
-        }
+        let meal = try await self.meal(
+            pumphistory: pumpHistoryJSON,
+            profile: profile,
+            basalProfile: basalProfile,
+            clock: dateFormattedAsString,
+            carbs: carbsAsJSON,
+            glucose: glucoseAsJSON
+        )
 
         // IOB
-        let iob: RawJSON = await withCheckedContinuation { continuation in
-            self.processQueue.async {
-                let result = self.iob(
-                    pumphistory: pumpHistoryJSON,
-                    profile: profile,
-                    clock: dateFormattedAsString,
-                    autosens: autosens.isEmpty ? .null : autosens
-                )
-                continuation.resume(returning: result)
-            }
-        }
+        let iob = try await self.iob(
+            pumphistory: pumpHistoryJSON,
+            profile: profile,
+            clock: dateFormattedAsString,
+            autosens: autosens.isEmpty ? .null : autosens
+        )
 
         storage.save(iob, as: Monitor.iob)
 
         // Determine basal
-        let orefDetermination: RawJSON = await withCheckedContinuation { continuation in
-            self.processQueue.async {
-                let result = self.determineBasal(
-                    glucose: glucoseAsJSON,
-                    currentTemp: tempBasal,
-                    iob: iob,
-                    profile: profile,
-                    autosens: autosens.isEmpty ? .null : autosens,
-                    meal: meal,
-                    microBolusAllowed: true,
-                    reservoir: reservoir,
-                    pumpHistory: pumpHistoryJSON,
-                    preferences: preferences,
-                    basalProfile: basalProfile,
-                    oref2_variables: oref2_variables
-                )
-                continuation.resume(returning: result)
-            }
-        }
+        let orefDetermination = try await determineBasal(
+            glucose: glucoseAsJSON,
+            currentTemp: tempBasal,
+            iob: iob,
+            profile: profile,
+            autosens: autosens.isEmpty ? .null : autosens,
+            meal: meal,
+            microBolusAllowed: true,
+            reservoir: reservoir,
+            pumpHistory: pumpHistoryJSON,
+            preferences: preferences,
+            basalProfile: basalProfile,
+            oref2_variables: oref2_variables
+        )
 
         debug(.openAPS, "Determinated: \(orefDetermination)")
 
@@ -464,31 +464,29 @@ final class OpenAPS {
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs()
         async let glucose = fetchAndProcessGlucose()
+        async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
+        async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
+        async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
 
         // Await the results of asynchronous tasks
-        let pumpHistoryJSON = await parsePumpHistory(await pumpHistoryObjectIDs)
-        let carbsAsJSON = await carbs
-        let glucoseAsJSON = await glucose
-
-        // Load files from Storage
-        let profile = loadFileFromStorage(name: Settings.profile)
-        let basalProfile = loadFileFromStorage(name: Settings.basalProfile)
-        let tempTargets = loadFileFromStorage(name: Settings.tempTargets)
+        let (pumpHistoryJSON, carbsAsJSON, glucoseAsJSON, profile, basalProfile, tempTargets) = await (
+            parsePumpHistory(await pumpHistoryObjectIDs),
+            carbs,
+            glucose,
+            getProfile,
+            getBasalProfile,
+            getTempTargets
+        )
 
         // Autosense
-        let autosenseResult: RawJSON = await withCheckedContinuation { continuation in
-            self.processQueue.async {
-                let result = self.autosense(
-                    glucose: glucoseAsJSON,
-                    pumpHistory: pumpHistoryJSON,
-                    basalprofile: basalProfile,
-                    profile: profile,
-                    carbs: carbsAsJSON,
-                    temptargets: tempTargets
-                )
-                continuation.resume(returning: result)
-            }
-        }
+        let autosenseResult = try await autosense(
+            glucose: glucoseAsJSON,
+            pumpHistory: pumpHistoryJSON,
+            basalprofile: basalProfile,
+            profile: profile,
+            carbs: carbsAsJSON,
+            temptargets: tempTargets
+        )
 
         debug(.openAPS, "AUTOSENS: \(autosenseResult)")
         if var autosens = Autosens(from: autosenseResult) {
@@ -508,148 +506,181 @@ final class OpenAPS {
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs()
         async let glucose = fetchAndProcessGlucose()
+        async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
+        async let getPumpProfile = loadFileFromStorageAsync(name: Settings.pumpProfile)
+        async let getPreviousAutotune = storage.retrieveAsync(Settings.autotune, as: RawJSON.self)
 
         // Await the results of asynchronous tasks
-        let pumpHistoryJSON = await parsePumpHistory(await pumpHistoryObjectIDs)
-        let carbsAsJSON = await carbs
-        let glucoseAsJSON = await glucose
-
-        // Load files from storage
-        let profile = loadFileFromStorage(name: Settings.profile)
-        let pumpProfile = loadFileFromStorage(name: Settings.pumpProfile)
-        let previousAutotune = storage.retrieve(Settings.autotune, as: RawJSON.self)
-
-        // Autotune
-        let autotunePreppedGlucose: RawJSON = await withCheckedContinuation { continuation in
-            self.processQueue.async {
-                let result = self.autotunePrepare(
-                    pumphistory: pumpHistoryJSON,
-                    profile: profile,
-                    glucose: glucoseAsJSON,
-                    pumpprofile: pumpProfile,
-                    carbs: carbsAsJSON,
-                    categorizeUamAsBasal: categorizeUamAsBasal,
-                    tuneInsulinCurve: tuneInsulinCurve
-                )
-                continuation.resume(returning: result)
-            }
-        }
-
-        debug(.openAPS, "AUTOTUNE PREP: \(autotunePreppedGlucose)")
+        let (pumpHistoryJSON, carbsAsJSON, glucoseAsJSON, profile, pumpProfile, previousAutotune) = await (
+            parsePumpHistory(await pumpHistoryObjectIDs),
+            carbs,
+            glucose,
+            getProfile,
+            getPumpProfile,
+            getPreviousAutotune
+        )
 
-        let autotuneResult: RawJSON = await withCheckedContinuation { continuation in
-            self.processQueue.async {
-                let result = self.autotuneRun(
-                    autotunePreparedData: autotunePreppedGlucose,
-                    previousAutotuneResult: previousAutotune ?? profile,
-                    pumpProfile: pumpProfile
-                )
-                continuation.resume(returning: result)
+        // Error need to be handled here because the function is not declared as throws
+        do {
+            // Autotune Prepare
+            let autotunePreppedGlucose = try await autotunePrepare(
+                pumphistory: pumpHistoryJSON,
+                profile: profile,
+                glucose: glucoseAsJSON,
+                pumpprofile: pumpProfile,
+                carbs: carbsAsJSON,
+                categorizeUamAsBasal: categorizeUamAsBasal,
+                tuneInsulinCurve: tuneInsulinCurve
+            )
+
+            debug(.openAPS, "AUTOTUNE PREP: \(autotunePreppedGlucose)")
+
+            // Autotune Run
+            let autotuneResult = try await autotuneRun(
+                autotunePreparedData: autotunePreppedGlucose,
+                previousAutotuneResult: previousAutotune ?? profile,
+                pumpProfile: pumpProfile
+            )
+
+            debug(.openAPS, "AUTOTUNE RESULT: \(autotuneResult)")
+
+            if let autotune = Autotune(from: autotuneResult) {
+                storage.save(autotuneResult, as: Settings.autotune)
+
+                return autotune
+            } else {
+                return nil
             }
-        }
-
-        debug(.openAPS, "AUTOTUNE RESULT: \(autotuneResult)")
-
-        if let autotune = Autotune(from: autotuneResult) {
-            storage.save(autotuneResult, as: Settings.autotune)
-
-            return autotune
-        } else {
+        } catch {
+            debug(.openAPS, "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to prepare/run Autotune")
             return nil
         }
     }
 
-    func makeProfiles(useAutotune: Bool) async -> Autotune? {
-        await withCheckedContinuation { continuation in
-            debug(.openAPS, "Start makeProfiles")
-            processQueue.async {
-                var preferences = self.loadFileFromStorage(name: Settings.preferences)
-                if preferences.isEmpty {
-                    preferences = Preferences().rawJSON
-                }
-                let pumpSettings = self.loadFileFromStorage(name: Settings.settings)
-                let bgTargets = self.loadFileFromStorage(name: Settings.bgTargets)
-                let basalProfile = self.loadFileFromStorage(name: Settings.basalProfile)
-                let isf = self.loadFileFromStorage(name: Settings.insulinSensitivities)
-                let cr = self.loadFileFromStorage(name: Settings.carbRatios)
-                let tempTargets = self.loadFileFromStorage(name: Settings.tempTargets)
-                let model = self.loadFileFromStorage(name: Settings.model)
-                let autotune = useAutotune ? self.loadFileFromStorage(name: Settings.autotune) : .empty
-                let freeaps = self.loadFileFromStorage(name: FreeAPS.settings)
-
-                let pumpProfile = self.makeProfile(
-                    preferences: preferences,
-                    pumpSettings: pumpSettings,
-                    bgTargets: bgTargets,
-                    basalProfile: basalProfile,
-                    isf: isf,
-                    carbRatio: cr,
-                    tempTargets: tempTargets,
-                    model: model,
-                    autotune: RawJSON.null,
-                    freeaps: freeaps
-                )
-
-                let profile = self.makeProfile(
-                    preferences: preferences,
-                    pumpSettings: pumpSettings,
-                    bgTargets: bgTargets,
-                    basalProfile: basalProfile,
-                    isf: isf,
-                    carbRatio: cr,
-                    tempTargets: tempTargets,
-                    model: model,
-                    autotune: autotune.isEmpty ? .null : autotune,
-                    freeaps: freeaps
-                )
+    func makeProfiles(useAutotune _: Bool) async -> Autotune? {
+        debug(.openAPS, "Start makeProfiles")
+
+        async let getPreferences = loadFileFromStorageAsync(name: Settings.preferences)
+        async let getPumpSettings = loadFileFromStorageAsync(name: Settings.settings)
+        async let getBGTargets = loadFileFromStorageAsync(name: Settings.bgTargets)
+        async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
+        async let getISF = loadFileFromStorageAsync(name: Settings.insulinSensitivities)
+        async let getCR = loadFileFromStorageAsync(name: Settings.carbRatios)
+        async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
+        async let getModel = loadFileFromStorageAsync(name: Settings.model)
+        async let getAutotune = loadFileFromStorageAsync(name: Settings.autotune)
+        async let getFreeAPS = loadFileFromStorageAsync(name: FreeAPS.settings)
+
+        let (preferences, pumpSettings, bgTargets, basalProfile, isf, cr, tempTargets, model, autotune, freeaps) = await (
+            getPreferences,
+            getPumpSettings,
+            getBGTargets,
+            getBasalProfile,
+            getISF,
+            getCR,
+            getTempTargets,
+            getModel,
+            getAutotune,
+            getFreeAPS
+        )
 
-                self.storage.save(pumpProfile, as: Settings.pumpProfile)
-                self.storage.save(profile, as: Settings.profile)
+        var adjustedPreferences = preferences
+        if adjustedPreferences.isEmpty {
+            adjustedPreferences = Preferences().rawJSON
+        }
 
-                if let tunedProfile = Autotune(from: profile) {
-                    continuation.resume(returning: tunedProfile)
-                } else {
-                    continuation.resume(returning: nil)
-                }
+        do {
+            // Pump Profile
+            let pumpProfile = try await makeProfile(
+                preferences: adjustedPreferences,
+                pumpSettings: pumpSettings,
+                bgTargets: bgTargets,
+                basalProfile: basalProfile,
+                isf: isf,
+                carbRatio: cr,
+                tempTargets: tempTargets,
+                model: model,
+                autotune: RawJSON.null,
+                freeaps: freeaps
+            )
+
+            // Profile
+            let profile = try await makeProfile(
+                preferences: adjustedPreferences,
+                pumpSettings: pumpSettings,
+                bgTargets: bgTargets,
+                basalProfile: basalProfile,
+                isf: isf,
+                carbRatio: cr,
+                tempTargets: tempTargets,
+                model: model,
+                autotune: autotune.isEmpty ? .null : autotune,
+                freeaps: freeaps
+            )
+
+            await storage.saveAsync(pumpProfile, as: Settings.pumpProfile)
+            await storage.saveAsync(profile, as: Settings.profile)
+
+            if let tunedProfile = Autotune(from: profile) {
+                return tunedProfile
+            } else {
+                return nil
             }
+        } catch {
+            debug(
+                .apsManager,
+                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to execute makeProfiles() to return Autoune results"
+            )
+            return nil
         }
     }
 
     // MARK: - Private
 
-    private func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) -> RawJSON {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return jsWorker.inCommonContext { worker in
-            worker.evaluateBatch(scripts: [
-                Script(name: Prepare.log),
-                Script(name: Bundle.iob),
-                Script(name: Prepare.iob)
-            ])
-            return worker.call(function: Function.generate, with: [
-                pumphistory,
-                profile,
-                clock,
-                autosens
-            ])
+    private func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) async throws -> RawJSON {
+        await withCheckedContinuation { continuation in
+            jsWorker.inCommonContext { worker in
+                worker.evaluateBatch(scripts: [
+                    Script(name: Prepare.log),
+                    Script(name: Bundle.iob),
+                    Script(name: Prepare.iob)
+                ])
+                let result = worker.call(function: Function.generate, with: [
+                    pumphistory,
+                    profile,
+                    clock,
+                    autosens
+                ])
+                continuation.resume(returning: result)
+            }
         }
     }
 
-    private func meal(pumphistory: JSON, profile: JSON, basalProfile: JSON, clock: JSON, carbs: JSON, glucose: JSON) -> RawJSON {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return jsWorker.inCommonContext { worker in
-            worker.evaluateBatch(scripts: [
-                Script(name: Prepare.log),
-                Script(name: Bundle.meal),
-                Script(name: Prepare.meal)
-            ])
-            return worker.call(function: Function.generate, with: [
-                pumphistory,
-                profile,
-                clock,
-                glucose,
-                basalProfile,
-                carbs
-            ])
+    private func meal(
+        pumphistory: JSON,
+        profile: JSON,
+        basalProfile: JSON,
+        clock: JSON,
+        carbs: JSON,
+        glucose: JSON
+    ) async throws -> RawJSON {
+        try await withCheckedThrowingContinuation { continuation in
+            jsWorker.inCommonContext { worker in
+                worker.evaluateBatch(scripts: [
+                    Script(name: Prepare.log),
+                    Script(name: Bundle.meal),
+                    Script(name: Prepare.meal)
+                ])
+                let result = worker.call(function: Function.generate, with: [
+                    pumphistory,
+                    profile,
+                    clock,
+                    glucose,
+                    basalProfile,
+                    carbs
+                ])
+                continuation.resume(returning: result)
+            }
         }
     }
 
@@ -660,22 +691,24 @@ final class OpenAPS {
         profile: JSON,
         carbs: JSON,
         temptargets: JSON
-    ) -> RawJSON {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return jsWorker.inCommonContext { worker in
-            worker.evaluateBatch(scripts: [
-                Script(name: Prepare.log),
-                Script(name: Bundle.autosens),
-                Script(name: Prepare.autosens)
-            ])
-            return worker.call(function: Function.generate, with: [
-                glucose,
-                pumpHistory,
-                basalprofile,
-                profile,
-                carbs,
-                temptargets
-            ])
+    ) async throws -> RawJSON {
+        try await withCheckedThrowingContinuation { continuation in
+            jsWorker.inCommonContext { worker in
+                worker.evaluateBatch(scripts: [
+                    Script(name: Prepare.log),
+                    Script(name: Bundle.autosens),
+                    Script(name: Prepare.autosens)
+                ])
+                let result = worker.call(function: Function.generate, with: [
+                    glucose,
+                    pumpHistory,
+                    basalprofile,
+                    profile,
+                    carbs,
+                    temptargets
+                ])
+                continuation.resume(returning: result)
+            }
         }
     }
 
@@ -687,39 +720,47 @@ final class OpenAPS {
         carbs: JSON,
         categorizeUamAsBasal: Bool,
         tuneInsulinCurve: Bool
-    ) -> RawJSON {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return jsWorker.inCommonContext { worker in
-            worker.evaluateBatch(scripts: [
-                Script(name: Prepare.log),
-                Script(name: Bundle.autotunePrep),
-                Script(name: Prepare.autotunePrep)
-            ])
-            return worker.call(function: Function.generate, with: [
-                pumphistory,
-                profile,
-                glucose,
-                pumpprofile,
-                carbs,
-                categorizeUamAsBasal,
-                tuneInsulinCurve
-            ])
+    ) async throws -> RawJSON {
+        try await withCheckedThrowingContinuation { continuation in
+            jsWorker.inCommonContext { worker in
+                worker.evaluateBatch(scripts: [
+                    Script(name: Prepare.log),
+                    Script(name: Bundle.autotunePrep),
+                    Script(name: Prepare.autotunePrep)
+                ])
+                let result = worker.call(function: Function.generate, with: [
+                    pumphistory,
+                    profile,
+                    glucose,
+                    pumpprofile,
+                    carbs,
+                    categorizeUamAsBasal,
+                    tuneInsulinCurve
+                ])
+                continuation.resume(returning: result)
+            }
         }
     }
 
-    private func autotuneRun(autotunePreparedData: JSON, previousAutotuneResult: JSON, pumpProfile: JSON) -> RawJSON {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return jsWorker.inCommonContext { worker in
-            worker.evaluateBatch(scripts: [
-                Script(name: Prepare.log),
-                Script(name: Bundle.autotuneCore),
-                Script(name: Prepare.autotuneCore)
-            ])
-            return worker.call(function: Function.generate, with: [
-                autotunePreparedData,
-                previousAutotuneResult,
-                pumpProfile
-            ])
+    private func autotuneRun(
+        autotunePreparedData: JSON,
+        previousAutotuneResult: JSON,
+        pumpProfile: JSON
+    ) async throws -> RawJSON {
+        try await withCheckedThrowingContinuation { continuation in
+            jsWorker.inCommonContext { worker in
+                worker.evaluateBatch(scripts: [
+                    Script(name: Prepare.log),
+                    Script(name: Bundle.autotuneCore),
+                    Script(name: Prepare.autotuneCore)
+                ])
+                let result = worker.call(function: Function.generate, with: [
+                    autotunePreparedData,
+                    previousAutotuneResult,
+                    pumpProfile
+                ])
+                continuation.resume(returning: result)
+            }
         }
     }
 
@@ -736,36 +777,39 @@ final class OpenAPS {
         preferences: JSON,
         basalProfile: JSON,
         oref2_variables: JSON
-    ) -> RawJSON {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return jsWorker.inCommonContext { worker in
-            worker.evaluateBatch(scripts: [
-                Script(name: Prepare.log),
-                Script(name: Prepare.determineBasal),
-                Script(name: Bundle.basalSetTemp),
-                Script(name: Bundle.getLastGlucose),
-                Script(name: Bundle.determineBasal)
-            ])
+    ) async throws -> RawJSON {
+        try await withCheckedThrowingContinuation { continuation in
+            jsWorker.inCommonContext { worker in
+                worker.evaluateBatch(scripts: [
+                    Script(name: Prepare.log),
+                    Script(name: Prepare.determineBasal),
+                    Script(name: Bundle.basalSetTemp),
+                    Script(name: Bundle.getLastGlucose),
+                    Script(name: Bundle.determineBasal)
+                ])
+
+                if let middleware = self.middlewareScript(name: OpenAPS.Middleware.determineBasal) {
+                    worker.evaluate(script: middleware)
+                }
 
-            if let middleware = self.middlewareScript(name: OpenAPS.Middleware.determineBasal) {
-                worker.evaluate(script: middleware)
-            }
+                let result = worker.call(function: Function.generate, with: [
+                    iob,
+                    currentTemp,
+                    glucose,
+                    profile,
+                    autosens,
+                    meal,
+                    microBolusAllowed,
+                    reservoir,
+                    Date(),
+                    pumpHistory,
+                    preferences,
+                    basalProfile,
+                    oref2_variables
+                ])
 
-            return worker.call(function: Function.generate, with: [
-                iob,
-                currentTemp,
-                glucose,
-                profile,
-                autosens,
-                meal,
-                microBolusAllowed,
-                reservoir,
-                Date(),
-                pumpHistory,
-                preferences,
-                basalProfile,
-                oref2_variables
-            ])
+                continuation.resume(returning: result)
+            }
         }
     }
 
@@ -792,26 +836,28 @@ final class OpenAPS {
         model: JSON,
         autotune: JSON,
         freeaps: JSON
-    ) -> RawJSON {
-        dispatchPrecondition(condition: .onQueue(processQueue))
-        return jsWorker.inCommonContext { worker in
-            worker.evaluateBatch(scripts: [
-                Script(name: Prepare.log),
-                Script(name: Bundle.profile),
-                Script(name: Prepare.profile)
-            ])
-            return worker.call(function: Function.generate, with: [
-                pumpSettings,
-                bgTargets,
-                isf,
-                basalProfile,
-                preferences,
-                carbRatio,
-                tempTargets,
-                model,
-                autotune,
-                freeaps
-            ])
+    ) async throws -> RawJSON {
+        try await withCheckedThrowingContinuation { continuation in
+            jsWorker.inCommonContext { worker in
+                worker.evaluateBatch(scripts: [
+                    Script(name: Prepare.log),
+                    Script(name: Bundle.profile),
+                    Script(name: Prepare.profile)
+                ])
+                let result = worker.call(function: Function.generate, with: [
+                    pumpSettings,
+                    bgTargets,
+                    isf,
+                    basalProfile,
+                    preferences,
+                    carbRatio,
+                    tempTargets,
+                    model,
+                    autotune,
+                    freeaps
+                ])
+                continuation.resume(returning: result)
+            }
         }
     }
 
@@ -823,6 +869,15 @@ final class OpenAPS {
         storage.retrieveRaw(name) ?? OpenAPS.defaults(for: name)
     }
 
+    private func loadFileFromStorageAsync(name: String) async -> RawJSON {
+        await withCheckedContinuation { continuation in
+            DispatchQueue.global(qos: .userInitiated).async {
+                let result = self.storage.retrieveRaw(name) ?? OpenAPS.defaults(for: name)
+                continuation.resume(returning: result)
+            }
+        }
+    }
+
     private func middlewareScript(name: String) -> Script? {
         if let body = storage.retrieveRaw(name) {
             return Script(name: "Middleware", body: body)

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

@@ -104,13 +104,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)
                         }
                         DatePicker("Date", selection: $state.date)
@@ -123,7 +123,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)
                         }
                         DatePicker("Date", selection: $state.date)

+ 37 - 38
FreeAPS/Sources/Modules/Bolus/View/BolusRootView.swift

@@ -6,6 +6,14 @@ import Swinject
 
 extension Bolus {
     struct RootView: BaseView {
+        enum FocusedField {
+            case carbs
+            case fat
+            case protein
+        }
+
+        @FocusState private var focusedField: FocusedField?
+
         let resolver: Resolver
 
         @StateObject var state = StateModel()
@@ -251,26 +259,32 @@ extension Bolus {
             HStack {
                 Text("Fat").foregroundColor(.orange)
                 Spacer()
-                DecimalTextField(
-                    "0",
-                    value: $state.fat,
-                    formatter: formatter,
-                    autofocus: false,
-                    cleanInput: true
-                )
+                TextFieldWithToolBar(text: $state.fat, placeholder: "0", numberFormatter: mealFormatter)
                 Text("g").foregroundColor(.secondary)
             }
             HStack {
                 Text("Protein").foregroundColor(.red)
                 Spacer()
-                DecimalTextField(
-                    "0",
-                    value: $state.protein,
-                    formatter: formatter,
-                    autofocus: false,
-                    cleanInput: true
-                ).foregroundColor(.loopRed)
+                TextFieldWithToolBar(text: $state.protein, placeholder: "0", numberFormatter: mealFormatter)
+                Text("g").foregroundColor(.secondary)
+            }
+        }
 
+        @ViewBuilder private func carbsTextField() -> some View {
+            HStack {
+                Text("Carbs").fontWeight(.semibold)
+                Spacer()
+                TextFieldWithToolBar(
+                    text: $state.carbs,
+                    placeholder: "0",
+                    shouldBecomeFirstResponder: true,
+                    numberFormatter: mealFormatter
+                )
+                .onChange(of: state.carbs) { _ in
+                    if state.carbs > 0 {
+                        handleDebouncedInput()
+                    }
+                }
                 Text("g").foregroundColor(.secondary)
             }
         }
@@ -280,22 +294,7 @@ extension Bolus {
                 VStack {
                     Form {
                         Section {
-                            HStack {
-                                Text("Carbs").fontWeight(.semibold)
-                                Spacer()
-                                DecimalTextField(
-                                    "0",
-                                    value: $state.carbs,
-                                    formatter: formatter,
-                                    autofocus: false,
-                                    cleanInput: true
-                                ).onChange(of: state.carbs) { _ in
-                                    if state.carbs > 0 {
-                                        handleDebouncedInput()
-                                    }
-                                }
-                                Text("g").foregroundColor(.secondary)
-                            }
+                            carbsTextField()
 
                             if state.useFPUconversion {
                                 proteinAndFat()
@@ -413,13 +412,12 @@ extension Bolus {
                             HStack {
                                 Text("Bolus")
                                 Spacer()
-                                DecimalTextField(
-                                    "0",
-                                    value: $state.amount,
-                                    formatter: formatter,
-                                    autofocus: false,
-                                    cleanInput: true,
-                                    textColor: .systemBlue
+                                TextFieldWithToolBar(
+                                    text: $state.amount,
+                                    placeholder: "0",
+                                    textColor: .blue,
+                                    maxLength: 5,
+                                    numberFormatter: formatter
                                 )
                                 Text(" U").foregroundColor(.secondary)
                             }
@@ -433,7 +431,8 @@ extension Bolus {
                             }
                         }.listRowBackground(Color.chart)
                     }
-                }.safeAreaInset(edge: .bottom, spacing: 0) {
+                }
+                .safeAreaInset(edge: .bottom, spacing: 0) {
                     stickyButton
                 }.blur(radius: state.waitForSuggestion ? 5 : 0)
 

+ 16 - 4
FreeAPS/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift

@@ -49,14 +49,18 @@ extension BolusCalculatorConfig {
                         HStack {
                             Text("Override With A Factor Of ")
                             Spacer()
-                            DecimalTextField("0.8", value: $state.overrideFactor, formatter: conversionFormatter)
+                            TextFieldWithToolBar(
+                                text: $state.overrideFactor,
+                                placeholder: "0.8",
+                                numberFormatter: conversionFormatter
+                            )
                         }
                     }
 
                     if !state.useCalc {
                         HStack {
                             Text("Recommended Bolus Percentage")
-                            DecimalTextField("", value: $state.insulinReqPercentage, formatter: formatter)
+                            TextFieldWithToolBar(text: $state.insulinReqPercentage, placeholder: "", numberFormatter: formatter)
                         }
                     }
                 } header: { Text("Calculator settings") }
@@ -74,7 +78,11 @@ extension BolusCalculatorConfig {
                         HStack {
                             Text("Override With A Factor Of ")
                             Spacer()
-                            DecimalTextField("0.7", value: $state.fattyMealFactor, formatter: conversionFormatter)
+                            TextFieldWithToolBar(
+                                text: $state.fattyMealFactor,
+                                placeholder: "0.7",
+                                numberFormatter: conversionFormatter
+                            )
                         }
                     } header: { Text("Fatty Meals") }
 
@@ -85,7 +93,11 @@ extension BolusCalculatorConfig {
                         HStack {
                             Text("Factor how often current basalrate is added")
                             Spacer()
-                            DecimalTextField("2", value: $state.sweetMealFactor, formatter: conversionFormatter)
+                            TextFieldWithToolBar(
+                                text: $state.sweetMealFactor,
+                                placeholder: "2",
+                                numberFormatter: conversionFormatter
+                            )
                         }
                     } header: { Text("Sweet Meals") }
 

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

@@ -45,13 +45,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 {

+ 4 - 6
FreeAPS/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -311,12 +311,10 @@ extension DataTable {
                         Section {
                             HStack {
                                 Text("New Glucose")
-                                DecimalTextField(
-                                    " ... ",
-                                    value: $state.manualGlucose,
-                                    formatter: manualGlucoseFormatter,
-                                    autofocus: true,
-                                    cleanInput: true
+                                TextFieldWithToolBar(
+                                    text: $state.manualGlucose,
+                                    placeholder: " ... ",
+                                    numberFormatter: manualGlucoseFormatter
                                 )
                                 Text(state.units.rawValue).foregroundStyle(.secondary)
                             }

+ 7 - 3
FreeAPS/Sources/Modules/Dynamic/View/DynamicRootView.swift

@@ -72,13 +72,13 @@ extension Dynamic {
                         HStack {
                             Text("Adjustment Factor")
                             Spacer()
-                            DecimalTextField("0", value: $state.adjustmentFactor, formatter: formatter)
+                            TextFieldWithToolBar(text: $state.adjustmentFactor, placeholder: "0", numberFormatter: formatter)
                         }
 
                         HStack {
                             Text("Weighted Average of TDD. Weight of past 24 hours:")
                             Spacer()
-                            DecimalTextField("0", value: $state.weightPercentage, formatter: formatter)
+                            TextFieldWithToolBar(text: $state.weightPercentage, placeholder: "0", numberFormatter: formatter)
                         }
 
                         HStack {
@@ -90,7 +90,11 @@ extension Dynamic {
                         HStack {
                             Text("Threshold Setting")
                             Spacer()
-                            DecimalTextField("0", value: $state.threshold_setting, formatter: glucoseFormatter)
+                            TextFieldWithToolBar(
+                                text: $state.threshold_setting,
+                                placeholder: "0",
+                                numberFormatter: glucoseFormatter
+                            )
                             Text(state.unit.rawValue)
                         }
                     } header: { Text("Safety") }

+ 8 - 4
FreeAPS/Sources/Modules/FPUConfig/View/FPUConfigRootView.swift

@@ -44,22 +44,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
+                        )
                     }
                 }
 

+ 2 - 1
FreeAPS/Sources/Modules/ISFEditor/ISFEditorStateModel.swift

@@ -10,6 +10,7 @@ extension ISFEditor {
         @Published var determinationsFromPersistence: [OrefDetermination] = []
 
         let context = CoreDataStack.shared.newTaskContext()
+        let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
         let timeValues = stride(from: 0.0, to: 1.days.timeInterval, by: 30.minutes.timeInterval).map { $0 }
 
@@ -117,7 +118,7 @@ extension ISFEditor {
         @MainActor private func updateDeterminationsArray(with IDs: [NSManagedObjectID]) {
             do {
                 let objects = try IDs.compactMap { id in
-                    try context.existingObject(with: id) as? OrefDetermination
+                    try viewContext.existingObject(with: id) as? OrefDetermination
                 }
                 determinationsFromPersistence = objects
 

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

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

+ 1 - 1
FreeAPS/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift

@@ -145,7 +145,7 @@ extension NightscoutConfig {
                     Toggle("Use local glucose server", isOn: $state.useLocalSource)
                     HStack {
                         Text("Port")
-                        DecimalTextField("", value: $state.localPort, formatter: portFormater)
+                        TextFieldWithToolBar(text: $state.localPort, placeholder: "", numberFormatter: portFormater)
                     }
                 } header: { Text("Local glucose source") }
                 Section {

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

@@ -99,14 +99,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)
                     }
                 }
@@ -115,7 +115,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)
                     }
                 }

+ 9 - 19
FreeAPS/Sources/Modules/OverrideProfilesConfig/View/OverrideProfilesRootView.swift

@@ -146,7 +146,7 @@ extension OverrideProfilesConfig {
                 if !state._indefinite {
                     HStack {
                         Text("Duration")
-                        DecimalTextField("0", value: $state.durationProfile, formatter: formatter, cleanInput: false)
+                        TextFieldWithToolBar(text: $state.durationProfile, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                 }
@@ -159,7 +159,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)
                     }
                 }
@@ -182,12 +182,12 @@ extension OverrideProfilesConfig {
                     if state.smbIsAlwaysOff {
                         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("Last Hour SMBs are Off (24 hours)")
-                            DecimalTextField("0", value: $state.end, formatter: formatter, cleanInput: false)
+                            TextFieldWithToolBar(text: $state.end, placeholder: "0", numberFormatter: formatter)
                             Text("hour").foregroundColor(.secondary)
                         }
                     }
@@ -210,22 +210,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)
                     }
                 }
@@ -392,13 +382,13 @@ extension OverrideProfilesConfig {
                     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.durationTT, formatter: formatter, cleanInput: true)
+                        TextFieldWithToolBar(text: $state.durationTT, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                     DatePicker("Date", selection: $state.date)
@@ -425,7 +415,7 @@ extension OverrideProfilesConfig {
                     HStack {
                         Text("Duration")
                         Spacer()
-                        DecimalTextField("0", value: $state.durationTT, formatter: formatter, cleanInput: true)
+                        TextFieldWithToolBar(text: $state.durationTT, placeholder: "0", numberFormatter: formatter)
                         Text("minutes").foregroundColor(.secondary)
                     }
                     DatePicker("Date", selection: $state.date)

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

@@ -74,10 +74,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(

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

@@ -35,22 +35,22 @@ 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)
                     }
                     HStack {
                         Text("Max Carbs")
-                        DecimalTextField("g", value: $state.maxCarbs, formatter: formatter)
+                        TextFieldWithToolBar(text: $state.maxCarbs, placeholder: "g", 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)
                     }
                 }
 

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

@@ -56,13 +56,13 @@ extension StatConfig {
                     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)
                     }
                     Toggle("Override HbA1c Unit", isOn: $state.overrideHbA1cUnit)

+ 24 - 0
FreeAPS/Sources/Services/Storage/FileStorage.swift

@@ -2,7 +2,9 @@ import Foundation
 
 protocol FileStorage {
     func save<Value: JSON>(_ value: Value, as name: String)
+    func saveAsync<Value: JSON>(_ value: Value, as name: String) async
     func retrieve<Value: JSON>(_ name: String, as type: Value.Type) -> Value?
+    func retrieveAsync<Value: JSON>(_ name: String, as type: Value.Type) async -> Value?
     func retrieveRaw(_ name: String) -> RawJSON?
     func append<Value: JSON>(_ newValue: Value, to name: String)
     func append<Value: JSON>(_ newValues: [Value], to name: String)
@@ -28,12 +30,34 @@ final class BaseFileStorage: FileStorage {
         }
     }
 
+    func saveAsync<Value: JSON>(_ value: Value, as name: String) async {
+        await withCheckedContinuation { continuation in
+            processQueue.safeSync {
+                if let value = value as? RawJSON, let data = value.data(using: .utf8) {
+                    try? Disk.save(data, to: .documents, as: name)
+                } else {
+                    try? Disk.save(value, to: .documents, as: name, encoder: JSONCoding.encoder)
+                }
+                continuation.resume()
+            }
+        }
+    }
+
     func retrieve<Value: JSON>(_ name: String, as type: Value.Type) -> Value? {
         processQueue.safeSync {
             try? Disk.retrieve(name, from: .documents, as: type, decoder: JSONCoding.decoder)
         }
     }
 
+    func retrieveAsync<Value: JSON>(_ name: String, as type: Value.Type) async -> Value? {
+        await withCheckedContinuation { continuation in
+            processQueue.safeSync {
+                let result = try? Disk.retrieve(name, from: .documents, as: type, decoder: JSONCoding.decoder)
+                continuation.resume(returning: result)
+            }
+        }
+    }
+
     func retrieveRaw(_ name: String) -> RawJSON? {
         processQueue.safeSync {
             guard let data = try? Disk.retrieve(name, from: .documents, as: Data.self) else {

+ 128 - 136
FreeAPS/Sources/Views/DecimalTextField.swift

@@ -1,171 +1,163 @@
-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
-    private var useButtons: Bool
-    private var textColor: UIColor?
-
-    init(
-        _ placeholder: String,
-        value: Binding<Decimal>,
-        formatter: NumberFormatter,
-        autofocus: Bool = false,
-        cleanInput: Bool = false,
-        useButtons: Bool = true,
-        textColor: UIColor? = nil
+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
+
+    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 = NumberFormatter()
     ) {
+        _text = text
         self.placeholder = placeholder
-        _value = value
-        self.formatter = formatter
-        self.autofocus = autofocus
-        self.cleanInput = cleanInput
-        self.useButtons = useButtons
         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
     }
 
-    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
-
-        if let textColor = textColor {
-            textfield.textColor = textColor
-        }
-
-        lazy var toolBar: UIToolbar = {
-            let tool: UIToolbar = .init(frame: .init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 35))
-            tool.barStyle = .default
-            tool.isTranslucent = true
-            tool.sizeToFit()
-
-            let spaceArea: UIBarButtonItem = .init(systemItem: .flexibleSpace)
-            let clearButton: UIBarButtonItem = .init(
-                title: "Clear",
-                style: .plain,
-                target: self,
-                action: #selector(textfield.clearButtonTapped(button:))
-            )
-            let doneButton: UIBarButtonItem = .init(
-                title: "Done",
-                style: .done,
-                target: self,
-                action: #selector(textfield.doneButtonTapped(button:))
-            )
-            tool.setItems([clearButton, spaceArea, doneButton], animated: false)
-            tool.isUserInteractionEnabled = true
-
-            return tool
-        }()
-
-        if useButtons {
-            textfield.inputAccessoryView = toolBar
-        }
+    public func makeUIView(context: Context) -> UITextField {
+        let textField = UITextField()
+        textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
+        textField.addTarget(context.coordinator, action: #selector(Coordinator.textChanged), for: .editingChanged)
+        textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
+        textField.delegate = context.coordinator
+        return textField
+    }
 
-        if autofocus {
-            DispatchQueue.main.async {
-                textfield.becomeFirstResponder()
-            }
-        }
-        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
     }
 
-    func updateUIView(_ textField: UITextField, context: Context) {
-        let coordinator = context.coordinator
-        if coordinator.isEditing {
-            coordinator.resetEditing()
-        } else if value == 0 {
-            textField.text = ""
+    public func updateUIView(_ textField: UITextField, context: Context) {
+        if text != 0 {
+            textField.text = numberFormatter.string(from: text as NSNumber)
         } else {
-            textField.text = formatter.string(for: value)
+            textField.text = ""
+        }
+        textField.placeholder = placeholder
+        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
         }
     }
 
-    func makeCoordinator() -> Coordinator {
-        Coordinator(self)
+    public func makeCoordinator() -> Coordinator {
+        Coordinator(self, maxLength: maxLength)
     }
 
-    class Coordinator: NSObject, UITextFieldDelegate {
-        var parent: DecimalTextField
-
-        init(_ textField: DecimalTextField) {
-            parent = textField
-        }
+    public final class Coordinator: NSObject {
+        var parent: TextFieldWithToolBar
+        let maxLength: Int?
 
-        private(set) var isEditing = false
-        private var editingCancellable: AnyCancellable?
+        var didBecomeFirstResponder = false
 
-        func resetEditing() {
-            editingCancellable = Just(false)
-                .delay(for: 0.5, scheduler: DispatchQueue.main)
-                .weakAssign(to: \.isEditing, on: self)
+        init(_ parent: TextFieldWithToolBar, maxLength: Int?) {
+            self.parent = parent
+            self.maxLength = maxLength
         }
 
-        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)
+        @objc fileprivate func textChanged(_ textField: UITextField) {
+            if let text = textField.text, let value = parent.numberFormatter.number(from: text)?.decimalValue {
+                parent.text = value
+            } else {
+                parent.text = 0
             }
+        }
 
-            return isNumber || withDecimal
+        @objc fileprivate func clearText() {
+            parent.text = 0
         }
 
-        func textFieldDidEndEditing(
-            _ textField: UITextField,
-            reason _: UITextField.DidEndEditingReason
-        ) {
-            // Format value with formatter at End Editing
-            textField.text = parent.formatter.string(for: parent.value)
-            isEditing = false
+        @objc fileprivate func editingDidBegin(_ textField: UITextField) {
+            DispatchQueue.main.async {
+                textField.moveCursorToEnd()
+            }
         }
     }
 }
 
-// MARK: extension for done button
-
-extension UITextField {
-    @objc func doneButtonTapped(button _: UIBarButtonItem) {
-        resignFirstResponder()
+extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
+    public func textField(
+        _ textField: UITextField,
+        shouldChangeCharactersIn range: NSRange,
+        replacementString string: String
+    ) -> Bool {
+        guard let maxLength = maxLength else {
+            return true
+        }
+        let currentString: NSString = (textField.text ?? "") as NSString
+        let newString: NSString =
+            currentString.replacingCharacters(in: range, with: string) as NSString
+        return newString.length <= maxLength
     }
 
-    @objc func clearButtonTapped(button _: UIBarButtonItem) {
-        text = ""
+    public func textFieldDidBeginEditing(_: UITextField) {
+        parent.textFieldDidBeginEditing?()
     }
 }
 
-// MARK: extension for keyboard to dismiss
+extension UITextField {
+    func moveCursorToEnd() {
+        dispatchPrecondition(condition: .onQueue(.main))
+        let newPosition = endOfDocument
+        selectedTextRange = textRange(from: newPosition, to: newPosition)
+    }
+}
 
 extension UIApplication {
     func endEditing() {