diff --git a/.github/workflows/add_identifiers.yml b/.github/workflows/add_identifiers.yml index 3a1bfc70992..2fe6142984e 100644 --- a/.github/workflows/add_identifiers.yml +++ b/.github/workflows/add_identifiers.yml @@ -16,7 +16,7 @@ jobs: steps: # Checks-out the repo - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Patch Fastlane Match to not print tables - name: Patch Match Tables diff --git a/.github/workflows/auto_version_dev.yml b/.github/workflows/auto_version_dev.yml index f94c93fe154..0f5536036e1 100644 --- a/.github/workflows/auto_version_dev.yml +++ b/.github/workflows/auto_version_dev.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.TRIO_TOKEN_AUTOBUMP }} diff --git a/.github/workflows/build_trio.yml b/.github/workflows/build_trio.yml index c8a752403a9..28a8dd94ac6 100644 --- a/.github/workflows/build_trio.yml +++ b/.github/workflows/build_trio.yml @@ -16,7 +16,7 @@ env: jobs: # use a single runner for these sequential steps check_status: - runs-on: ubuntu-latest + runs-on: self-hosted name: Check status to decide whether to build permissions: contents: write @@ -26,19 +26,18 @@ jobs: # Check GH_PAT, sync repository, check day in month steps: - - name: Access id: workflow-permission run: | # Validate Access Token - + # Ensure that gh exit codes are handled when output is piped. set -o pipefail - + # Define patterns to validate the access token (GH_PAT) and distinguish between classic and fine-grained tokens. GH_PAT_CLASSIC_PATTERN='^ghp_[a-zA-Z0-9]{36}$' GH_PAT_FINE_GRAINED_PATTERN='^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$' - + # Validate Access Token (GH_PAT) if [ -z "$GH_PAT" ]; then failed=true @@ -80,7 +79,7 @@ jobs: echo "has_permission=true" >> $GITHUB_OUTPUT fi fi - + # Exit unsuccessfully if secret validation failed. if [ $failed ]; then exit 2 @@ -90,7 +89,7 @@ jobs: if: | steps.workflow-permission.outputs.has_permission == 'true' && (vars.SCHEDULED_BUILD != 'false' || vars.SCHEDULED_SYNC != 'false') - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.GH_PAT }} @@ -100,7 +99,7 @@ jobs: steps.workflow-permission.outputs.has_permission == 'true' && vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'nightscout' id: sync - uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1 + uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2 with: target_sync_branch: ${{ env.TARGET_BRANCH }} shallow_since: 6 months ago @@ -152,20 +151,20 @@ jobs: # creates new certs if the repository variable ENABLE_NUKE_CERTS == 'true' # only run if a build is planned check_certs: - needs: [check_status] - name: Check certificates - uses: ./.github/workflows/create_certs.yml - secrets: inherit - if: | - github.event_name == 'workflow_dispatch' || - (vars.SCHEDULED_BUILD != 'false' && needs.check_status.outputs.IS_SECOND_IN_MONTH == 'true') || - (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) + needs: [check_status] + name: Check certificates + uses: ./.github/workflows/create_certs.yml + secrets: inherit + if: | + github.event_name == 'workflow_dispatch' || + (vars.SCHEDULED_BUILD != 'false' && needs.check_status.outputs.IS_SECOND_IN_MONTH == 'true') || + (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) # Builds Trio build: name: Build needs: [check_certs, check_status] - runs-on: macos-26 + runs-on: self-hosted permissions: contents: write if: @@ -175,10 +174,10 @@ jobs: (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) steps: - name: Select Xcode version - run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" - + run: "SUDO_ASKPASS=/usr/local/bin/askpass.sh sudo -A xcode-select --switch /Applications/Xcode-26.2.0.app/Contents/Developer" + - name: Checkout Repo for building - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: token: ${{ secrets.GH_PAT }} submodules: recursive @@ -219,7 +218,7 @@ jobs: run: | TABLE_PRINTER_PATH=$(ruby -e 'puts Gem::Specification.find_by_name("fastlane").gem_dir')/match/lib/match/table_printer.rb if [ -f "$TABLE_PRINTER_PATH" ]; then - sed -i "" "/puts(Terminal::Table.new(params))/d" "$TABLE_PRINTER_PATH" + SUDO_ASKPASS=/usr/local/bin/askpass.sh sudo -A sed -i "" "/puts(Terminal::Table.new(params))/d" "$TABLE_PRINTER_PATH" else echo "table_printer.rb not found" exit 1 @@ -231,7 +230,7 @@ jobs: # Sync the GitHub runner clock with the Windows time server (workaround as suggested in https://github.com/actions/runner/issues/2996) - name: Sync clock - run: sudo sntp -sS time.windows.com + run: SUDO_ASKPASS=/usr/local/bin/askpass.sh sudo -A sntp -sS time.windows.com # Build signed Trio IPA file - name: Fastlane Build & Archive @@ -258,7 +257,7 @@ jobs: # Upload Build artifacts - name: Upload build log, IPA and Symbol artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: build-artifacts path: | diff --git a/.github/workflows/create_certs.yml b/.github/workflows/create_certs.yml index 430fbe1fdab..e8f998afb1f 100644 --- a/.github/workflows/create_certs.yml +++ b/.github/workflows/create_certs.yml @@ -28,7 +28,7 @@ jobs: steps: # Checks-out the repo - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # Patch Fastlane Match to not print tables - name: Patch Match Tables @@ -97,7 +97,7 @@ jobs: run: echo "new_certificate_needed=${{ needs.create_certs.outputs.new_certificate_needed }}" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install dependencies run: bundle install diff --git a/.github/workflows/stale_issues.yml b/.github/workflows/stale_issues.yml index 7252afefd46..4f7e3e1ce41 100644 --- a/.github/workflows/stale_issues.yml +++ b/.github/workflows/stale_issues.yml @@ -11,7 +11,7 @@ jobs: pull-requests: write if: github.repository_owner == 'nightscout' steps: - - uses: actions/stale@v9.0.0 + - uses: actions/stale@v10 with: days-before-issue-stale: 30 days-before-issue-close: 14 @@ -32,7 +32,7 @@ jobs: pull-requests: write if: github.repository_owner == 'nightscout' steps: - - uses: actions/stale@v9.0.0 + - uses: actions/stale@v10 with: days-before-issue-stale: 30 days-before-issue-close: 30 diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 2032514d96d..255f880cc87 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -31,14 +31,14 @@ jobs: run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 submodules: recursive - name: Restore cache id: cache-restore - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | /Users/runner/Library/Developer/Xcode/DerivedData @@ -94,7 +94,7 @@ jobs: - name: Save cache if: steps.cache-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | /Users/runner/Library/Developer/Xcode/DerivedData diff --git a/.github/workflows/validate_secrets.yml b/.github/workflows/validate_secrets.yml index d30842026f6..ea150c0c53d 100644 --- a/.github/workflows/validate_secrets.yml +++ b/.github/workflows/validate_secrets.yml @@ -116,7 +116,7 @@ jobs: TEAMID: ${{ secrets.TEAMID }} steps: - name: Checkout Repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Project Dependencies run: bundle install diff --git a/.gitmodules b/.gitmodules index ca2bf7849f1..93412e5a983 100644 --- a/.gitmodules +++ b/.gitmodules @@ -41,3 +41,7 @@ [submodule "DanaKit"] path = DanaKit url = https://github.com/loopandlearn/DanaKit +[submodule "MedtrumKit"] + path = MedtrumKit + branch = dev + url = https://github.com/loopandlearn/MedtrumKit diff --git a/Config.xcconfig b/Config.xcconfig index f192da1c1fa..68f5ee85d24 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -9,7 +9,7 @@ DEVELOPER_TEAM = ##TEAM_ID## // Typically this is not modified unless you want to create a separate (unique) app using your ID // It must include $(DEVELOPMENT_TEAM) // For example: myOwnApp.$(DEVELOPMENT_TEAM).trio -BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio +BUNDLE_IDENTIFIER = de.artpancreas.$(DEVELOPMENT_TEAM).FreeAPS // Danger zone - do not modify these unless you know what you are doing @@ -19,11 +19,11 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group // The developers set the version numbers, please leave them alone APP_VERSION = 0.6.0 -APP_DEV_VERSION = 0.6.0.64 +APP_DEV_VERSION = 0.6.0.74 APP_BUILD_NUMBER = 1 COPYRIGHT_NOTICE = // Optional overrides - these can be used to insert your TEAMID into the DEVELOPER_TEAM field #include? "../../ConfigOverride.xcconfig" #include? "../ConfigOverride.xcconfig" -#include? "ConfigOverride.xcconfig" \ No newline at end of file +#include? "ConfigOverride.xcconfig" diff --git a/MedtrumKit b/MedtrumKit new file mode 160000 index 00000000000..b7f3d44c06b --- /dev/null +++ b/MedtrumKit @@ -0,0 +1 @@ +Subproject commit b7f3d44c06bb7c580be897e0414e64de2d6dd995 diff --git a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents index c0a3461b4c9..332c3373a55 100644 --- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents +++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -78,7 +78,7 @@ - + diff --git a/Trio Watch App Extension/Helper/WatchNotificationHandler.swift b/Trio Watch App Extension/Helper/WatchNotificationHandler.swift new file mode 100644 index 00000000000..7a76bd88173 --- /dev/null +++ b/Trio Watch App Extension/Helper/WatchNotificationHandler.swift @@ -0,0 +1,66 @@ +import Foundation +import UserNotifications +import WatchConnectivity + +final class WatchNotificationHandler: NSObject, UNUserNotificationCenterDelegate { + static let shared = WatchNotificationHandler() + + override private init() { + super.init() + } + + func configure() { + let center = UNUserNotificationCenter.current() + center.delegate = self + registerCategories(on: center) + } + + private func registerCategories(on center: UNUserNotificationCenter) { + center.getNotificationCategories { existingCategories in + let glucoseCategory = NotificationCategoryFactory.createGlucoseCategory() + + var categories = existingCategories + categories.update(with: glucoseCategory) + // UNUserNotificationCenter methods should be called on main thread + Task { @MainActor in + center.setNotificationCategories(categories) + } + } + } + + /// UNUserNotificationCenterDelegate method called when user interacts with a notification on watch. + /// This can be called off the main thread. WCSession.transferUserInfo is thread-safe. + func userNotificationCenter( + _: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + defer { completionHandler() } + + guard let action = NotificationResponseAction(rawValue: response.actionIdentifier) else { return } + sendSnoozeRequest(for: action) + } + + /// Sends snooze request to iPhone via WatchConnectivity. + /// WCSession.transferUserInfo is thread-safe and can be called from any thread. + /// Relies on the watch app's WCSession owner (e.g., WatchState) to handle + /// session activation and delegate management. + private func sendSnoozeRequest(for action: NotificationResponseAction) { + guard WCSession.isSupported() else { return } + + let payload: [String: Any] = [WatchMessageKeys.snoozeDuration: action.minutes] + let session = WCSession.default + + // Try sendMessage first if session is reachable and activated (faster, immediate delivery) + // Fall back to transferUserInfo if not reachable or if sendMessage fails + if session.isReachable, session.activationState == .activated { + session.sendMessage(payload, replyHandler: nil) { _ in + // Fallback to transferUserInfo if sendMessage fails + session.transferUserInfo(payload) + } + } else { + // Session not reachable or not activated - use transferUserInfo (queued delivery) + session.transferUserInfo(payload) + } + } +} diff --git a/Trio Watch App Extension/TrioWatchApp.swift b/Trio Watch App Extension/TrioWatchApp.swift index 34eafbfca37..fe8e7f96cef 100644 --- a/Trio Watch App Extension/TrioWatchApp.swift +++ b/Trio Watch App Extension/TrioWatchApp.swift @@ -1,8 +1,13 @@ import SwiftUI +import UserNotifications @main struct TrioWatchApp: App { @Environment(\.scenePhase) private var scenePhase + init() { + WatchNotificationHandler.shared.configure() + } + var body: some Scene { WindowGroup { TrioMainWatchView() diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index fe0aeb3472a..fb45e17b2de 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -9,6 +9,19 @@ /* Begin PBXBuildFile section */ 041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */; }; 0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5822B15939E719628E9FF7C /* SnoozeRootView.swift */; }; + 0961457D2F0BEEDE003E06D3 /* NutritionTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0961457C2F0BEEDE003E06D3 /* NutritionTextField.swift */; }; + 096F54772EF21D4B00B557C1 /* NutritionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 096F54762EF21D4B00B557C1 /* NutritionEditorView.swift */; }; + 0977A2262EF1F35C00A1AF88 /* BarcodeScannerStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0977A2252EF1F35200A1AF88 /* BarcodeScannerStateModel.swift */; }; + 09A169602EE302EB0026711C /* BarcodeScannerDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A1695B2EE302EB0026711C /* BarcodeScannerDataFlow.swift */; }; + 09A169642EE302EB0026711C /* BarcodeScannerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A1695C2EE302EB0026711C /* BarcodeScannerProvider.swift */; }; + 09A169652EE302EB0026711C /* BarcodeScannerRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A169582EE302EB0026711C /* BarcodeScannerRootView.swift */; }; + 09A169682EE3030D0026711C /* OpenBarcodeScannerIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A169662EE3030D0026711C /* OpenBarcodeScannerIntent.swift */; }; + 09A169792EE302EB0026711D /* OpenFoodFactsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A169712EE302EB0026711D /* OpenFoodFactsModels.swift */; }; + 09A1697B2EE302EB0026711D /* ScannerPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A169732EE302EB0026711D /* ScannerPreviewView.swift */; }; + 09A1697C2EE302EB0026711D /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A169742EE302EB0026711D /* ProductViews.swift */; }; + 09D758522F31671B00F9055E /* ProductSearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09D758512F31671B00F9055E /* ProductSearchField.swift */; }; + 09D758532F31671B00F9055E /* FoodSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09D758502F31671B00F9055E /* FoodSearchResultRow.swift */; }; + 09F000722F01D6E800835F6D /* OpenFoodFactsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09F000712F01D6E800835F6D /* OpenFoodFactsClient.swift */; }; 0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */; }; 0F7A65FBD2CD8D6477ED4539 /* GlucoseNotificationSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */; }; 110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE02C5193D100615CC9 /* BolusIntent.swift */; }; @@ -260,6 +273,8 @@ 3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; }; 3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; }; 3E28F2AB2EB5337F00FB9EEB /* ConnectIQ in Frameworks */ = {isa = PBXBuildFile; productRef = 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */; }; + 3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; }; + 3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */; }; 45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; }; 45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; }; @@ -461,6 +476,9 @@ C2AA6CF72E1A734A00BF6C16 /* SettingsExportProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */; }; C2AA6CF82E1A734A00BF6C16 /* SettingsExportDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */; }; C2AA6CF92E1A734A00BF6C16 /* SettingsExportStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AA6CF42E1A734A00BF6C16 /* SettingsExportStateModel.swift */; }; + C2BA6B972F758E7500348E6A /* WatchNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */; }; + C2BA6B992F758E7600348E6A /* NotificationIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */; }; + C2BA6B9A2F7593C300348E6A /* NotificationIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */; }; C967DACD3B1E638F8B43BE06 /* ManualTempBasalStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */; }; CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; }; CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; }; @@ -790,6 +808,7 @@ 3B4BA7792D8DBD690069D5B8 /* MinimedKit.framework in Embed Frameworks */, CE95BF5C2BA770C300DC3DE3 /* LoopKit.framework in Embed Frameworks */, 3B4BA7712D8DBD690069D5B8 /* G7SensorKit.framework in Embed Frameworks */, + 3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */, CEB434FE28B90B8C00B70274 /* SwiftCharts in Embed Frameworks */, 3B4BA7812D8DBD690069D5B8 /* OmniKitUI.framework in Embed Frameworks */, 3B4BA76F2D8DBD690069D5B8 /* DanaKit.framework in Embed Frameworks */, @@ -848,6 +867,19 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0961457C2F0BEEDE003E06D3 /* NutritionTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NutritionTextField.swift; sourceTree = ""; }; + 096F54762EF21D4B00B557C1 /* NutritionEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NutritionEditorView.swift; sourceTree = ""; }; + 0977A2252EF1F35200A1AF88 /* BarcodeScannerStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerStateModel.swift; sourceTree = ""; }; + 09A169582EE302EB0026711C /* BarcodeScannerRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerRootView.swift; sourceTree = ""; }; + 09A1695B2EE302EB0026711C /* BarcodeScannerDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerDataFlow.swift; sourceTree = ""; }; + 09A1695C2EE302EB0026711C /* BarcodeScannerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerProvider.swift; sourceTree = ""; }; + 09A169662EE3030D0026711C /* OpenBarcodeScannerIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenBarcodeScannerIntent.swift; sourceTree = ""; }; + 09A169712EE302EB0026711D /* OpenFoodFactsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenFoodFactsModels.swift; sourceTree = ""; }; + 09A169732EE302EB0026711D /* ScannerPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerPreviewView.swift; sourceTree = ""; }; + 09A169742EE302EB0026711D /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = ""; }; + 09D758502F31671B00F9055E /* FoodSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoodSearchResultRow.swift; sourceTree = ""; }; + 09D758512F31671B00F9055E /* ProductSearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductSearchField.swift; sourceTree = ""; }; + 09F000712F01D6E800835F6D /* OpenFoodFactsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenFoodFactsClient.swift; sourceTree = ""; }; 110AEDE02C5193D100615CC9 /* BolusIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntent.swift; sourceTree = ""; }; 110AEDE12C5193D100615CC9 /* BolusIntentRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntentRequest.swift; sourceTree = ""; }; 110AEDE52C51A0AE00615CC9 /* ShortcutsConfigView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShortcutsConfigView.swift; sourceTree = ""; }; @@ -1093,6 +1125,7 @@ 3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = ""; }; 3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = ""; }; 3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = ""; }; + 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = ""; }; 3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = ""; }; 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = ""; }; @@ -1296,6 +1329,8 @@ C2AA6CF22E1A734A00BF6C16 /* SettingsExportDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportDataFlow.swift; sourceTree = ""; }; C2AA6CF32E1A734A00BF6C16 /* SettingsExportProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportProvider.swift; sourceTree = ""; }; C2AA6CF42E1A734A00BF6C16 /* SettingsExportStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsExportStateModel.swift; sourceTree = ""; }; + C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNotificationHandler.swift; sourceTree = ""; }; + C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIdentifiers.swift; sourceTree = ""; }; C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalRootView.swift; sourceTree = ""; }; C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsDataFlow.swift; sourceTree = ""; }; CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawFetchedProfile.swift; sourceTree = ""; }; @@ -1584,6 +1619,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 097CDEE42F02B1C40091EFC8 /* UnionTabView in Frameworks */, CE95BF632BA771BE00DC3DE3 /* LoopTestingKit.framework in Frameworks */, 3B4BA7722D8DBD690069D5B8 /* G7SensorKitUI.framework in Frameworks */, 3B4BA7742D8DBD690069D5B8 /* LibreTransmitter.framework in Frameworks */, @@ -1592,6 +1628,7 @@ 3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */, 3B4BA7782D8DBD690069D5B8 /* MinimedKit.framework in Frameworks */, 3B4BA7762D8DBD690069D5B8 /* LibreTransmitterUI.framework in Frameworks */, + 3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */, 3B4BA7902D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Frameworks */, 3B4BA76A2D8DBD690069D5B8 /* CGMBLEKit.framework in Frameworks */, 3B4BA77C2D8DBD690069D5B8 /* OmniBLE.framework in Frameworks */, @@ -1678,6 +1715,38 @@ path = ConfigEditor; sourceTree = ""; }; + 09A1695A2EE302EB0026711C /* View */ = { + isa = PBXGroup; + children = ( + 09A169582EE302EB0026711C /* BarcodeScannerRootView.swift */, + 096F54762EF21D4B00B557C1 /* NutritionEditorView.swift */, + 09A169732EE302EB0026711D /* ScannerPreviewView.swift */, + 09A169742EE302EB0026711D /* ProductViews.swift */, + 09D758502F31671B00F9055E /* FoodSearchResultRow.swift */, + 09D758512F31671B00F9055E /* ProductSearchField.swift */, + ); + path = View; + sourceTree = ""; + }; + 09A1695F2EE302EB0026711C /* BarcodeScanner */ = { + isa = PBXGroup; + children = ( + 09A1695A2EE302EB0026711C /* View */, + 0977A2252EF1F35200A1AF88 /* BarcodeScannerStateModel.swift */, + 09A1695B2EE302EB0026711C /* BarcodeScannerDataFlow.swift */, + 09A1695C2EE302EB0026711C /* BarcodeScannerProvider.swift */, + ); + path = BarcodeScanner; + sourceTree = ""; + }; + 09A169672EE3030D0026711C /* BarcodeScanner */ = { + isa = PBXGroup; + children = ( + 09A169662EE3030D0026711C /* OpenBarcodeScannerIntent.swift */, + ); + path = BarcodeScanner; + sourceTree = ""; + }; 0A67A70F9438DB6586398458 /* View */ = { isa = PBXGroup; children = ( @@ -1904,6 +1973,7 @@ DDF690FE2DA2C9EE008BF16C /* AppDiagnostics */, DD1745422C55C5C400211FAC /* AutosensSettings */, A42F1FEDFFD0DDE00AAD54D3 /* BasalProfileEditor */, + 09A1695F2EE302EB0026711C /* BarcodeScanner */, 3811DE0425C9D32E00A708ED /* Base */, BD7DA9A32AE06DBA00601B20 /* BolusCalculatorConfig */, DD09D4792C5986BA003FEA5D /* CalendarEventSettings */, @@ -2197,6 +2267,7 @@ 3818AA48274C267000843DB3 /* Frameworks */ = { isa = PBXGroup; children = ( + 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */, 3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */, 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */, 3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */, @@ -2305,6 +2376,7 @@ DD6A4E072DBD95F1008C4B26 /* BluetoothRequiredView.swift */, 3811DE5925C9D4D500A708ED /* ViewModifiers.swift */, 3883581B25EE79BB00E024B2 /* TextFieldWithToolBar.swift */, + 0961457C2F0BEEDE003E06D3 /* NutritionTextField.swift */, 383420D825FFEB3F002D46C1 /* Popup.swift */, 389ECDFD2601061500D86C4F /* View+Snapshot.swift */, 38EA05FF262091870064E39B /* BolusProgressViewStyle.swift */, @@ -2433,6 +2505,8 @@ DD6B7CB32C7B71F700B75029 /* ForecastDisplayType.swift */, DD9ECB692CA99F6C00AA7C45 /* CommandPayload.swift */, DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */, + 09A169712EE302EB0026711D /* OpenFoodFactsModels.swift */, + C2BA6B982F758E7600348E6A /* NotificationIdentifiers.swift */, ); path = Models; sourceTree = ""; @@ -2466,6 +2540,7 @@ 38FCF3D525E8FDF40078B0D1 /* MD5.swift */, 38E98A2C25F52DC400C0CED0 /* NSLocking+Extensions.swift */, 38C4D33925E9A1ED00D30B77 /* NSObject+AssociatedValues.swift */, + 09F000712F01D6E800835F6D /* OpenFoodFactsClient.swift */, 3811DE5725C9D4D500A708ED /* ProgressBar.swift */, DD82D4B72DCAB2BA00BAFC77 /* PropertyPersistentFlags.swift */, 3811DEE325CA063400A708ED /* PropertyWrappers */, @@ -3122,6 +3197,7 @@ DD4C57A42D73ADDA001BFF2C /* LiveActivity */, 118DF7692C5ECBC60067FEB7 /* Override */, 110AEDE22C5193D100615CC9 /* Bolus */, + 09A169672EE3030D0026711C /* BarcodeScanner */, CE1856F32ADC4835007E39C7 /* Carbs */, CE7CA3432A064973004BE681 /* AppShortcuts.swift */, CE7CA3442A064973004BE681 /* BaseIntentsRequest.swift */, @@ -3384,6 +3460,7 @@ DD09D5C62D29EB26000D82C9 /* Helper+Enums.swift */, DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */, DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */, + C2BA6B962F758E7500348E6A /* WatchNotificationHandler.swift */, ); path = Helper; sourceTree = ""; @@ -3835,6 +3912,7 @@ 3B47C60F2DA0A28F00B0E5EF /* FirebaseCrashlytics */, DD17A0282E3FE0BD008E1BF0 /* SwiftJWT */, 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */, + 096349182EF98C23003C9C36 /* UnionTabView */, ); productName = Trio; productReference = 388E595825AD948C0019842D /* Trio.app */; @@ -4013,6 +4091,7 @@ 3B47C60E2DA0A28F00B0E5EF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, DD868FD92E381E1C005D3308 /* XCRemoteSwiftPackageReference "Swift-JWT" */, 3E28F2A92EB5337F00FB9EEB /* XCRemoteSwiftPackageReference "connectiq-companion-app-sdk-ios" */, + 096349172EF98C23003C9C36 /* XCRemoteSwiftPackageReference "union-tab-view" */, ); productRefGroup = 388E595925AD948C0019842D /* Products */; projectDirPath = ""; @@ -4216,6 +4295,7 @@ DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */, 3811DE3025C9D49500A708ED /* HomeStateModel.swift in Sources */, 38BF021725E7CBBC00579895 /* PumpManagerExtensions.swift in Sources */, + 09A169682EE3030D0026711C /* OpenBarcodeScannerIntent.swift in Sources */, 118DF76E2C5ECBC60067FEB7 /* OverridePresetsIntentRequest.swift in Sources */, CEE9A6552BBB418300EB5194 /* CalibrationsProvider.swift in Sources */, 19F95FF529F10FCF00314DDC /* StatProvider.swift in Sources */, @@ -4493,6 +4573,7 @@ 491D6FC02D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift in Sources */, DD1745442C55C60E00211FAC /* AutosensSettingsDataFlow.swift in Sources */, BD249DA72D42FE4600412DEB /* Calendar+GlucoseStatsChart.swift in Sources */, + 0977A2262EF1F35C00A1AF88 /* BarcodeScannerStateModel.swift in Sources */, BDCAF2382C639F35002DC907 /* SettingItems.swift in Sources */, DD6A4E082DBD95F1008C4B26 /* BluetoothRequiredView.swift in Sources */, 58D08B342C8DF9A700AA37D3 /* CobIobChart.swift in Sources */, @@ -4505,6 +4586,7 @@ 53F2382465BF74DB1A967C8B /* PumpConfigProvider.swift in Sources */, 5D16287A969E64D18CE40E44 /* PumpConfigStateModel.swift in Sources */, DDF6902C2DA028D3008BF16C /* DiagnosticsStepView.swift in Sources */, + C2BA6B992F758E7600348E6A /* NotificationIdentifiers.swift in Sources */, 19D466AA29AA3099004D5F33 /* MealSettingsRootView.swift in Sources */, CEF1ED6B2D58FB5800FAF41E /* CGMOptions.swift in Sources */, E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */, @@ -4522,6 +4604,8 @@ 389ECDFE2601061500D86C4F /* View+Snapshot.swift in Sources */, 38FEF3FE2738083E00574A46 /* CGMSettingsProvider.swift in Sources */, 38E98A3725F5509500C0CED0 /* String+Extensions.swift in Sources */, + 09D758522F31671B00F9055E /* ProductSearchField.swift in Sources */, + 09D758532F31671B00F9055E /* FoodSearchResultRow.swift in Sources */, CC76E9512BD4812E008BEB61 /* Forecast+helper.swift in Sources */, DD1745242C55526000211FAC /* SMBSettingsStateModel.swift in Sources */, F90692D1274B99B60037068D /* HealthKitProvider.swift in Sources */, @@ -4628,6 +4712,7 @@ CE82E02728E869DF00473A9C /* AlertEntry.swift in Sources */, DD30786A2D42F94000DE0490 /* GarminDevice.swift in Sources */, 38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */, + 096F54772EF21D4B00B557C1 /* NutritionEditorView.swift in Sources */, BD2FF1A02AE29D43005D1C5D /* ToggleStyles.swift in Sources */, DDD163162C4C690300CD525A /* AdjustmentsDataFlow.swift in Sources */, BDF34F932C10D0E100D51995 /* LiveActivityAttributes+Helper.swift in Sources */, @@ -4684,6 +4769,12 @@ 195D80B92AF697F700D25097 /* DynamicSettingsProvider.swift in Sources */, DD3F1F832D9DC78800DCE7B3 /* UnitSelectionStepView.swift in Sources */, DD09D47D2C5986DA003FEA5D /* CalendarEventSettingsProvider.swift in Sources */, + 09A169602EE302EB0026711C /* BarcodeScannerDataFlow.swift in Sources */, + 09A169642EE302EB0026711C /* BarcodeScannerProvider.swift in Sources */, + 09A169652EE302EB0026711C /* BarcodeScannerRootView.swift in Sources */, + 09A169792EE302EB0026711D /* OpenFoodFactsModels.swift in Sources */, + 09A1697B2EE302EB0026711D /* ScannerPreviewView.swift in Sources */, + 09A1697C2EE302EB0026711D /* ProductViews.swift in Sources */, DD09D47B2C5986D1003FEA5D /* CalendarEventSettingsDataFlow.swift in Sources */, DD1745202C55523E00211FAC /* SMBSettingsDataFlow.swift in Sources */, D6D02515BBFBE64FEBE89856 /* HistoryRootView.swift in Sources */, @@ -4710,6 +4801,8 @@ 3171D2818C7C72CD1584BB5E /* GlucoseNotificationSettingsStateModel.swift in Sources */, DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */, DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */, + 09F000722F01D6E800835F6D /* OpenFoodFactsClient.swift in Sources */, + 0961457D2F0BEEDE003E06D3 /* NutritionTextField.swift in Sources */, DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */, BDC530FF2D0F6BE300088832 /* ContactImageManager.swift in Sources */, BDC531122D1060FA00088832 /* ContactImageDetailView.swift in Sources */, @@ -4810,6 +4903,7 @@ BDA25F202D26D5FE00035F34 /* CarbsInputView.swift in Sources */, BDA25F1C2D26BD0700035F34 /* TrendShape.swift in Sources */, BDFF7A892D25F97D0016C40C /* TrioWatchApp.swift in Sources */, + C2BA6B972F758E7500348E6A /* WatchNotificationHandler.swift in Sources */, DD09D5C72D29EB2F000D82C9 /* Helper+Enums.swift in Sources */, BD54A9742D281AEF00F9C1EE /* TempTargetPresetWatch.swift in Sources */, BD54A95C2D2808A300F9C1EE /* OverridePresetWatch.swift in Sources */, @@ -4820,6 +4914,7 @@ DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */, DD246F062D2836AA0027DDE0 /* GlucoseTrendView.swift in Sources */, BD432CA22D2F4E4000D1EB79 /* WatchMessageKeys.swift in Sources */, + C2BA6B9A2F7593C300348E6A /* NotificationIdentifiers.swift in Sources */, DD8262CB2D289297009F6F62 /* BolusConfirmationView.swift in Sources */, BD54A9712D281A8100F9C1EE /* TempTargetPresetsView.swift in Sources */, DD3A3CE92D29C97800AE478E /* Helper+ButtonStyles.swift in Sources */, @@ -5466,6 +5561,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 096349172EF98C23003C9C36 /* XCRemoteSwiftPackageReference "union-tab-view" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/unionst/union-tab-view.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; 3811DE0E25C9D37700A708ED /* XCRemoteSwiftPackageReference "Swinject" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Swinject/Swinject"; @@ -5557,6 +5660,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 096349182EF98C23003C9C36 /* UnionTabView */ = { + isa = XCSwiftPackageProductDependency; + package = 096349172EF98C23003C9C36 /* XCRemoteSwiftPackageReference "union-tab-view" */; + productName = UnionTabView; + }; 3811DE0F25C9D37700A708ED /* Swinject */ = { isa = XCSwiftPackageProductDependency; package = 3811DE0E25C9D37700A708ED /* XCRemoteSwiftPackageReference "Swinject" */; diff --git a/Trio.xcworkspace/contents.xcworkspacedata b/Trio.xcworkspace/contents.xcworkspacedata index 8035089c5cc..5c7c148fc8c 100644 --- a/Trio.xcworkspace/contents.xcworkspacedata +++ b/Trio.xcworkspace/contents.xcworkspacedata @@ -16,6 +16,9 @@ + + diff --git a/Trio/Resources/Assets.xcassets/Colors/UAM.colorset/Contents.json b/Trio/Resources/Assets.xcassets/Colors/UAM.colorset/Contents.json index ab3c666295d..f4429d77973 100644 --- a/Trio/Resources/Assets.xcassets/Colors/UAM.colorset/Contents.json +++ b/Trio/Resources/Assets.xcassets/Colors/UAM.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.969", - "green" : "0.169", - "red" : "0.820" + "blue" : "0.427", + "green" : "0.741", + "red" : "0.780" } }, "idiom" : "universal" diff --git a/Trio/Resources/Info.plist b/Trio/Resources/Info.plist index 6ceab61b7b9..3a468eff48b 100644 --- a/Trio/Resources/Info.plist +++ b/Trio/Resources/Info.plist @@ -74,13 +74,15 @@ NSBluetoothPeripheralUsageDescription Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices NSCalendarsFullAccessUsageDescription - To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay + To create events with glucose values, so they can be viewed on Apple Watch and CarPlay NSCalendarsUsageDescription Calendar is used to create a new glucose events. + NSCameraUsageDescription + Camera is used to scan food barcodes for nutritional information NSContactsUsageDescription Contact is used to create a Apple Watch complication NSFaceIDUsageDescription - For authorized acces to bolus + For authorized access to bolus NSHealthShareUsageDescription Health App is used to store blood glucose, carbs and insulin NSHealthUpdateUsageDescription @@ -108,7 +110,7 @@ audio UIDesignRequiresCompatibility - + UIFileSharingEnabled UILaunchScreen diff --git a/Trio/Resources/InfoPlist.xcstrings b/Trio/Resources/InfoPlist.xcstrings index 82e1dead5a0..54de55c8c41 100644 --- a/Trio/Resources/InfoPlist.xcstrings +++ b/Trio/Resources/InfoPlist.xcstrings @@ -457,22 +457,22 @@ } } }, - "NSCalendarsUsageDescription" : { - "comment" : "Privacy - Calendars Usage Description", + "NSCalendarsFullAccessUsageDescription" : { + "comment" : "Privacy - Calendars Full Access Usage Description", "extractionState" : "extracted_with_value", "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." - } - }, - "ca" : { + "en" : { "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." + "state" : "new", + "value" : "To create events with glucose values, so they can be viewed on Apple Watch and CarPlay" } - }, + } + } + }, + "NSCalendarsUsageDescription" : { + "comment" : "Privacy - Calendars Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { "da" : { "stringUnit" : { "state" : "translated", @@ -488,19 +488,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "Calendar is used to create a new glucose events." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." + "value" : "Calendar is used to create new glucose events." } }, "fr" : { @@ -509,18 +497,6 @@ "value" : "Le calendrier est utilisé pour créer un nouvel événement de glycémie." } }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." - } - }, "it" : { "stringUnit" : { "state" : "translated", @@ -539,24 +515,6 @@ "value" : "Agenda wordt gebruikt om nieuwe glucose gebeurtenissen aan te maken." } }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Calendar is used to create a new glucose events." - } - }, "ru" : { "stringUnit" : { "state" : "translated", @@ -616,18 +574,6 @@ "comment" : "Privacy - Face ID Usage Description", "extractionState" : "extracted_with_value", "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, "da" : { "stringUnit" : { "state" : "translated", @@ -643,19 +589,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "For authorized acces to bolus" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" + "value" : "For authorized access to bolus" } }, "fr" : { @@ -664,18 +598,6 @@ "value" : "Pour les accès autorisés au bolus" } }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, "it" : { "stringUnit" : { "state" : "translated", @@ -694,24 +616,6 @@ "value" : "Voor geautoriseerde toegang tot bolus" } }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "For authorized acces to bolus" - } - }, "ru" : { "stringUnit" : { "state" : "translated", diff --git a/Trio/Sources/APS/APSManager.swift b/Trio/Sources/APS/APSManager.swift index c9d20513144..2ac1490c1f3 100644 --- a/Trio/Sources/APS/APSManager.swift +++ b/Trio/Sources/APS/APSManager.swift @@ -18,6 +18,7 @@ protocol APSManager { var lastLoopDateSubject: PassthroughSubject { get } var bolusProgress: CurrentValueSubject { get } var pumpExpiresAtDate: CurrentValueSubject { get } + var pumpActivatedAtDate: CurrentValueSubject { get } var isManualTempBasal: Bool { get } var isScheduledBasal: Bool? { get } var isSuspended: Bool { get } @@ -129,6 +130,10 @@ final class BaseAPSManager: APSManager, Injectable { deviceDataManager.pumpExpiresAtDate } + var pumpActivatedAtDate: CurrentValueSubject { + deviceDataManager.pumpActivatedAtDate + } + var settings: TrioSettings { get { settingsManager.settings } set { settingsManager.settings = newValue } diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index 26f5cba63cd..d835c39ab74 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -5,6 +5,7 @@ import DanaKit import Foundation import LoopKit import LoopKitUI +import MedtrumKit import MinimedKit import MockKit import OmniBLE @@ -27,6 +28,7 @@ protocol DeviceDataManager: GlucoseSource { var errorSubject: PassthroughSubject { get } var pumpName: CurrentValueSubject { get } var pumpExpiresAtDate: CurrentValueSubject { get } + var pumpActivatedAtDate: CurrentValueSubject { get } func heartbeat(date: Date) func createBolusProgressReporter() -> DoseProgressReporter? @@ -38,6 +40,7 @@ private let staticPumpManagers: [PumpManagerUI.Type] = [ OmnipodPumpManager.self, OmniBLEPumpManager.self, DanaKitPumpManager.self, + MedtrumPumpManager.self, MockPumpManager.self ] @@ -46,6 +49,7 @@ private let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [ OmnipodPumpManager.pluginIdentifier: OmnipodPumpManager.self, OmniBLEPumpManager.pluginIdentifier: OmniBLEPumpManager.self, DanaKitPumpManager.pluginIdentifier: DanaKitPumpManager.self, + MedtrumPumpManager.pluginIdentifier: MedtrumPumpManager.self, MockPumpManager.pluginIdentifier: MockPumpManager.self ] @@ -106,6 +110,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { storage.save(modifiedPreferences, as: OpenAPS.Settings.preferences) if let omnipod = pumpManager as? OmnipodPumpManager { + pumpActivatedAtDate.send(nil) guard let endTime = omnipod.state.podState?.expiresAt else { pumpExpiresAtDate.send(nil) return @@ -113,12 +118,27 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { pumpExpiresAtDate.send(endTime) } if let omnipodBLE = pumpManager as? OmniBLEPumpManager { + pumpActivatedAtDate.send(nil) guard let endTime = omnipodBLE.state.podState?.expiresAt else { pumpExpiresAtDate.send(nil) return } pumpExpiresAtDate.send(endTime) } + if let medtrumPump = pumpManager as? MedtrumPumpManager { + guard let endTime = medtrumPump.state.patchExpiresAt else { + pumpExpiresAtDate.send(nil) + return + } + pumpExpiresAtDate.send(endTime) + + switch medtrumPump.state.expiryMode { + case .default: + pumpActivatedAtDate.send(nil) + case .extended: + pumpActivatedAtDate.send(medtrumPump.state.patchActivatedAt) + } + } if let simulatorPump = pumpManager as? MockPumpManager { pumpDisplayState.value = PumpDisplayState(name: simulatorPump.localizedTitle, image: simulatorPump.smallImage) pumpName.send(simulatorPump.localizedTitle) @@ -163,6 +183,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { } else { pumpDisplayState.value = nil pumpExpiresAtDate.send(nil) + pumpActivatedAtDate.send(nil) pumpName.send("") // Reset bolusIncrement setting to default value, which is 0.1 U var modifiedPreferences = settingsManager.preferences @@ -202,6 +223,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { let pumpDisplayState = CurrentValueSubject(nil) let pumpExpiresAtDate = CurrentValueSubject(nil) + let pumpActivatedAtDate = CurrentValueSubject(nil) let pumpName = CurrentValueSubject("Pump") init(resolver: Resolver) { @@ -460,6 +482,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { manualTempBasal.send(false) } + pumpActivatedAtDate.send(nil) guard let endTime = omnipod.state.podState?.expiresAt else { pumpExpiresAtDate.send(nil) return @@ -493,6 +516,7 @@ extension BaseDeviceDataManager: PumpManagerDelegate { manualTempBasal.send(false) } + pumpActivatedAtDate.send(nil) guard let endTime = omnipodBLE.state.podState?.expiresAt else { pumpExpiresAtDate.send(nil) return @@ -504,6 +528,26 @@ extension BaseDeviceDataManager: PumpManagerDelegate { } } + if let medtrumPump = pumpManager as? MedtrumPumpManager { + storage.save(Decimal(medtrumPump.state.reservoir), as: OpenAPS.Monitor.reservoir) + broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { + $0.pumpReservoirDidChange(Decimal(medtrumPump.state.reservoir)) + } + + guard let endTime = medtrumPump.state.patchExpiresAt else { + pumpExpiresAtDate.send(nil) + return + } + pumpExpiresAtDate.send(endTime) + + switch medtrumPump.state.expiryMode { + case .default: + pumpActivatedAtDate.send(nil) + case .extended: + pumpActivatedAtDate.send(medtrumPump.state.patchActivatedAt) + } + } + if let simulatorPump = pumpManager as? MockPumpManager { broadcaster.notify(PumpReservoirObserver.self, on: processQueue) { $0.pumpReservoirDidChange(Decimal(simulatorPump.state.reservoirUnitsRemaining)) diff --git a/Trio/Sources/APS/FetchGlucoseManager.swift b/Trio/Sources/APS/FetchGlucoseManager.swift index 37a03230c2c..3f9573d9e7a 100644 --- a/Trio/Sources/APS/FetchGlucoseManager.swift +++ b/Trio/Sources/APS/FetchGlucoseManager.swift @@ -240,37 +240,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable { return Manager.init(rawState: rawState) } - func fetchGlucose(context: NSManagedObjectContext) async throws -> [NSManagedObjectID] { - // Compound predicate: time window + non-manual + valid date - let timePredicate = NSPredicate.predicateForOneDayAgoInMinutes - let manualPredicate = NSPredicate(format: "isManual == NO") - let datePredicate = NSPredicate(format: "date != nil") - - let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - timePredicate, - manualPredicate, - datePredicate - ]) - - let results = try await CoreDataStack.shared.fetchEntitiesAsync( - ofType: GlucoseStored.self, - onContext: context, - // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers. - // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h), - // this fetch window must be expanded accordingly. - predicate: compoundPredicate, - key: "date", - ascending: true, // the first element is the oldest - fetchLimit: 350 - ) - - guard let glucoseArray = results as? [GlucoseStored] else { - throw CoreDataError.fetchError(function: #function, file: #file) - } - - return glucoseArray.map(\.objectID) - } - private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws { // calibration add if required only for sensor let newGlucose = overcalibrate(entries: glucose) @@ -394,6 +363,37 @@ extension BaseFetchGlucoseManager: SettingsObserver { } extension BaseFetchGlucoseManager { + func fetchGlucose(context: NSManagedObjectContext) async throws -> [NSManagedObjectID] { + // Compound predicate: time window + non-manual + valid date + let timePredicate = NSPredicate.predicateForOneDayAgoInMinutes + let manualPredicate = NSPredicate(format: "isManual == NO") + let datePredicate = NSPredicate(format: "date != nil") + + let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + timePredicate, + manualPredicate, + datePredicate + ]) + + let results = try await CoreDataStack.shared.fetchEntitiesAsync( + ofType: GlucoseStored.self, + onContext: context, + // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers. + // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h), + // this fetch window must be expanded accordingly. + predicate: compoundPredicate, + key: "date", + ascending: true, // the first element is the oldest + fetchLimit: 350 + ) + + guard let glucoseArray = results as? [GlucoseStored] else { + throw CoreDataError.fetchError(function: #function, file: #file) + } + + return glucoseArray.map(\.objectID) + } + /// CoreData-friendly AAPS exponential smoothing + storage. /// - Important: Only stores `smoothedGlucose`. UI/alerts should still use `glucose`. /// @@ -474,12 +474,17 @@ extension BaseFetchGlucoseManager { } } - // If insufficient valid readings: copy raw into smoothed (clamped) for all passed entries. + // Not enough recent contiguous readings to smooth (e.g. after CGM gap). + // IMPORTANT: Only apply fallback to the recent window, not all data. + // Otherwise a recent gap would overwrite historical smoothed values. guard validWindowCount >= minimumWindowSize else { - for object in data { + let recentWindow = data.suffix(validWindowCount) + + for object in recentWindow { let raw = Decimal(Int(object.glucose)) object.smoothedGlucose = max(raw, minimumSmoothedGlucose) as NSDecimalNumber } + return } diff --git a/Trio/Sources/APS/OpenAPS/OpenAPS.swift b/Trio/Sources/APS/OpenAPS/OpenAPS.swift index 030023d62cf..14309f5ba5a 100644 --- a/Trio/Sources/APS/OpenAPS/OpenAPS.swift +++ b/Trio/Sources/APS/OpenAPS/OpenAPS.swift @@ -132,7 +132,7 @@ final class OpenAPS { return glucoseResults.map { glucose -> AlgorithmGlucose in let glucoseValue: Int16 - if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose { + if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0 { glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value } else { glucoseValue = glucose.glucose diff --git a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift index 43670197e93..1742a366ca0 100644 --- a/Trio/Sources/APS/Storage/PumpHistoryStorage.swift +++ b/Trio/Sources/APS/Storage/PumpHistoryStorage.swift @@ -291,13 +291,16 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable { } func determineBolusEventType(for event: PumpEventStored) -> PumpEventStored.EventType { - if event.bolus!.isSMB { + guard let bolus = event.bolus else { + return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus + } + if bolus.isSMB { return .smb } - if event.bolus!.isExternal { + if bolus.isExternal { return .isExternal } - return PumpEventStored.EventType(rawValue: event.type!) ?? PumpEventStored.EventType.bolus + return event.type.flatMap({ PumpEventStored.EventType(rawValue: $0) }) ?? .bolus } func getPumpHistoryNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] { diff --git a/Trio/Sources/Application/TrioApp.swift b/Trio/Sources/Application/TrioApp.swift index 1d148c93d49..a5d6ed3ab1c 100644 --- a/Trio/Sources/Application/TrioApp.swift +++ b/Trio/Sources/Application/TrioApp.swift @@ -8,6 +8,7 @@ extension Notification.Name { static let initializationCompleted = Notification.Name("initializationCompleted") static let initializationError = Notification.Name("initializationError") static let onboardingCompleted = Notification.Name("onboardingCompleted") + static let openBarcode = Notification.Name("openBarcode") } @main struct TrioApp: App { diff --git a/Trio/Sources/Helpers/OpenFoodFactsClient.swift b/Trio/Sources/Helpers/OpenFoodFactsClient.swift new file mode 100644 index 00000000000..b09ccf5e53d --- /dev/null +++ b/Trio/Sources/Helpers/OpenFoodFactsClient.swift @@ -0,0 +1,404 @@ +import Foundation + +// MARK: - OpenFoodFacts API Client + +extension BarcodeScanner { + /// Client for fetching product data from OpenFoodFacts API + struct OpenFoodFactsClient { + func fetchProduct(barcode: String) async throws -> FoodItem { + guard + let url = + URL( + string: + "https://world.openfoodfacts.org/api/v2/product/\(barcode).json&fields=code,product_name,image_url,image_front_small_url,nutriments,serving_quantity_unit,serving_quantity,product_quantity,product_quantity_unit" + ) + else { + throw OpenFoodFactsError.invalidResponse + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw OpenFoodFactsError.invalidResponse + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + // Try to decode the response even for 404s, as the API returns useful JSON + let apiResponse = try decoder.decode(APIResponse.self, from: data) + + // Check if product was found based on status field in JSON + guard apiResponse.status == 1, let productData = apiResponse.product else { + throw OpenFoodFactsError.productNotFound + } + + // For other HTTP errors (5xx, etc.), throw invalidResponse + guard 200 ..< 500 ~= httpResponse.statusCode else { + throw OpenFoodFactsError.invalidResponse + } + + // Decide preferred portion unit for user input + let servingUnit = + productData.servingQuantityUnit?.lowercased() + ?? productData.productQuantityUnit?.lowercased() + let servingSize = productData.servingQuantity ?? productData.productQuantity + let isMlQuantityUnit: Bool = { + if let unit = servingUnit { + if unit.contains("ml") || unit.contains("l") || unit.contains("fl oz") { + return true + } + return false + } + return productData.nutriments?.basis == .per100ml + }() + + var imageSource: FoodItem.ImageSource = .none + if let url = productData.imageURL { + imageSource = .url(url) + } + + return FoodItem( + barcode: apiResponse.code, + name: productData.productName?.trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? String(localized: "Unknown product"), + brand: productData.primaryBrand, + quantity: productData.quantity, + servingSize: productData.servingSize, + ingredients: productData.ingredientsText, + imageSource: imageSource, + defaultPortionIsMl: isMlQuantityUnit, + servingQuantity: productData.servingQuantity, + servingQuantityUnit: productData.servingQuantityUnit, + nutriments: .init( + basis: productData.nutriments?.basis ?? .per100g, + energyKcalPer100g: productData.nutriments?.energyKcal100g, + carbohydratesPer100g: productData.nutriments?.carbohydrates100g, + sugarsPer100g: productData.nutriments?.sugars100g, + fatPer100g: productData.nutriments?.fat100g, + proteinPer100g: productData.nutriments?.proteins100g, + fiberPer100g: productData.nutriments?.fiber100g + ) + ) + } + + /// Search products by name/text query + /// - Parameters: + /// - query: The search term to look for + /// - page: Page number for pagination (1-indexed) + /// - pageSize: Number of results per page + /// - Returns: Array of matching FoodItems + func searchProducts(query: String, page: Int = 1, pageSize: Int = 24) async throws -> [FoodItem] + { + guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + + guard var components = URLComponents(string: "https://world.openfoodfacts.org/cgi/search.pl") + else { + throw OpenFoodFactsError.invalidResponse + } + + components.queryItems = [ + URLQueryItem(name: "search_terms", value: query), + URLQueryItem(name: "search_simple", value: "1"), + URLQueryItem(name: "action", value: "process"), + URLQueryItem(name: "json", value: "1"), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)) + ] + + guard let url = components.url else { + throw OpenFoodFactsError.invalidResponse + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Trio-iOS/1.0", forHTTPHeaderField: "User-Agent") + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData // No cache + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + 200 ..< 300 ~= httpResponse.statusCode + else { + throw OpenFoodFactsError.invalidResponse + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let searchResponse = try decoder.decode(SearchAPIResponse.self, from: data) + + return searchResponse.products.compactMap { productData -> FoodItem? in + let servingUnit = + productData.servingQuantityUnit?.lowercased() + ?? productData.productQuantityUnit? + .lowercased() + let isMlQuantityUnit: Bool = { + if let unit = servingUnit { + if unit.contains("ml") || unit.contains("l") || unit.contains("fl oz") { + return true + } + return false + } + return productData.nutriments?.basis == .per100ml + }() + + var imageSource: FoodItem.ImageSource = .none + if let url = productData.imageURL { + imageSource = .url(url) + } + + return FoodItem( + barcode: productData.code, + name: productData.productName?.trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? String(localized: "Unknown product"), + brand: productData.primaryBrand, + quantity: productData.quantity, + servingSize: productData.servingSize, + ingredients: productData.ingredientsText, + imageSource: imageSource, + defaultPortionIsMl: isMlQuantityUnit, + servingQuantity: productData.servingQuantity, + servingQuantityUnit: productData.servingQuantityUnit, + nutriments: .init( + basis: productData.nutriments?.basis ?? .per100g, + energyKcalPer100g: productData.nutriments?.energyKcal100g, + carbohydratesPer100g: productData.nutriments?.carbohydrates100g, + sugarsPer100g: productData.nutriments?.sugars100g, + fatPer100g: productData.nutriments?.fat100g, + proteinPer100g: productData.nutriments?.proteins100g, + fiberPer100g: productData.nutriments?.fiber100g + ) + ) + } + } + } +} + +// MARK: - Errors + +extension BarcodeScanner { + enum OpenFoodFactsError: LocalizedError { + case invalidResponse + case productNotFound + + var errorDescription: String? { + switch self { + case .invalidResponse: + String(localized: "Unable to reach OpenFoodFacts. Please try again.") + case .productNotFound: + String( + localized: + "We couldn’t find this barcode in OpenFoodFacts. Maybe add the product to OpenFoodFacts via the App." + ) + } + } + } +} + +// MARK: - Private API Response Types + +private extension BarcodeScanner.OpenFoodFactsClient { + struct APIResponse: Decodable { + let status: Int + let statusVerbose: String + let code: String + let product: ProductData? + } + + /// Response structure for search API endpoint + struct SearchAPIResponse: Decodable { + let count: Int + let page: Int + let pageSize: Int + let products: [ProductData] + } + + struct ProductData: Decodable { + let code: String? + let productName: String? + let brands: String? + let quantity: String? + let productQuantityUnit: String? + let servingSize: String? + let servingQuantity: Double? + let servingQuantityUnit: String? + let productQuantity: Double? + let ingredientsText: String? + let imageUrl: String? + let imageFrontUrl: String? + let imageFrontThumbUrl: String? + let nutriments: NutrimentsData? + + private enum CodingKeys: String, CodingKey { + case code + case productName + case brands + case quantity + case productQuantityUnit + case servingSize + case servingQuantity + case productQuantity + case servingQuantityUnit + case ingredientsText + case imageUrl + case imageFrontUrl + case imageFrontThumbUrl + case nutriments + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(String.self, forKey: .code) + productName = try container.decodeIfPresent(String.self, forKey: .productName) + brands = try container.decodeIfPresent(String.self, forKey: .brands) + quantity = try container.decodeIfPresent(String.self, forKey: .quantity) + productQuantityUnit = try container.decodeIfPresent(String.self, forKey: .productQuantityUnit) + servingSize = try container.decodeIfPresent(String.self, forKey: .servingSize) + servingQuantityUnit = try container.decodeIfPresent(String.self, forKey: .servingQuantityUnit) + ingredientsText = try container.decodeIfPresent(String.self, forKey: .ingredientsText) + imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) + imageFrontUrl = try container.decodeIfPresent(String.self, forKey: .imageFrontUrl) + imageFrontThumbUrl = try container.decodeIfPresent(String.self, forKey: .imageFrontThumbUrl) + nutriments = try container.decodeIfPresent(NutrimentsData.self, forKey: .nutriments) + + // servingQuantity can be either a Double or a String in the API response + if let doubleValue = try? container.decodeIfPresent(Double.self, forKey: .servingQuantity) { + servingQuantity = doubleValue + } else if let stringValue = try? container.decodeIfPresent( + String.self, forKey: .servingQuantity + ), + let parsed = Double(stringValue.replacingOccurrences(of: ",", with: ".")) + { + servingQuantity = parsed + } else { + servingQuantity = nil + } + + // Handle productQuantity (can be Double or String) + if let doubleValue = try? container.decodeIfPresent(Double.self, forKey: .productQuantity) { + productQuantity = doubleValue + } else if let stringValue = try? container.decodeIfPresent( + String.self, forKey: .productQuantity + ), + let parsed = Double(stringValue.replacingOccurrences(of: ",", with: ".")) + { + productQuantity = parsed + } else { + productQuantity = nil + } + } + + var primaryBrand: String? { + brands? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first + } + + var imageURL: URL? { + [imageFrontUrl, imageFrontThumbUrl, imageUrl] + .compactMap { $0 } + .compactMap { URL(string: $0) } + .first + } + } + + struct NutrimentsData: Decodable { + let basis: BarcodeScanner.FoodItem.Nutriments.Basis + let energyKcal100g: Double? + let carbohydrates100g: Double? + let sugars100g: Double? + let fat100g: Double? + let proteins100g: Double? + let fiber100g: Double? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: NumericValue].self) + + func value(_ key: String, fallbacks: [String] = []) -> Double? { + if let v = raw[key]?.doubleValue { + return v + } + for fb in fallbacks { + if let v = raw[fb]?.doubleValue { + return v + } + } + return nil + } + + energyKcal100g = value( + "energy-kcal_100g", + fallbacks: ["energy-kcal_100ml", "energy-kcal_serving"] + ) + carbohydrates100g = value( + "carbohydrates_100g", + fallbacks: ["carbohydrates_100ml", "carbohydrates_serving"] + ) + sugars100g = value( + "sugars_100g", + fallbacks: ["sugars_100ml", "sugars_serving"] + ) + fat100g = value( + "fat_100g", + fallbacks: ["fat_100ml", "fat_serving"] + ) + proteins100g = value( + "proteins_100g", + fallbacks: ["proteins_100ml", "proteins_serving"] + ) + fiber100g = value( + "fiber_100g", + fallbacks: ["fiber_100ml", "fiber_serving"] + ) + + // Decide if data is per 100g or per 100ml based on available keys + let hasPer100g = raw.keys.contains { $0.hasSuffix("_100g") } + let hasPer100ml = raw.keys.contains { $0.hasSuffix("_100ml") } + + if hasPer100ml, !hasPer100g { + basis = .per100ml + } else { + basis = .per100g + } + } + + /// Helper type that can decode either a number or a string and expose it as Double + private struct NumericValue: Decodable { + let doubleValue: Double? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let d = try? container.decode(Double.self) { + doubleValue = d + return + } + + if let s = try? container.decode(String.self) { + doubleValue = Double(s.replacingOccurrences(of: ",", with: ".")) + return + } + + doubleValue = nil + } + } + } +} + +// MARK: - String Extension + +private extension Optional where Wrapped == String { + var nonEmpty: String? { + guard let trimmed = self?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty + else { + return nil + } + return trimmed + } +} diff --git a/Trio/Sources/Helpers/UIDevice+Extensions.swift b/Trio/Sources/Helpers/UIDevice+Extensions.swift index 5316c1bbf0e..6f8857f46f5 100644 --- a/Trio/Sources/Helpers/UIDevice+Extensions.swift +++ b/Trio/Sources/Helpers/UIDevice+Extensions.swift @@ -1,75 +1,101 @@ import SwiftUI +#if canImport(UIKit) + import UIKit +#endif -extension UIDevice { - var getDeviceId: String { - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } +#if canImport(UIKit) + extension UIDevice { + var getDeviceId: String { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } - func mapToDevice(identifier: String) -> String { - switch identifier { - case "iPhone10,4": - return "iPhone 8" - case "iPhone10,5": - return "iPhone 8 Plus" - case "iPhone10,6": - return "iPhone X" + func mapToDevice(identifier: String) -> String { + switch identifier { + case "iPhone10,4": + return "iPhone 8" + case "iPhone10,5": + return "iPhone 8 Plus" + case "iPhone10,6": + return "iPhone X" - case "iPhone11,2": - return "iPhone Xs" - case "iPhone11,8": - return "iPhone XR" + case "iPhone11,2": + return "iPhone Xs" + case "iPhone11,8": + return "iPhone XR" - case "iPhone12,1": - return "iPhone 11" - case "iPhone12,5": - return "iPhone 11 Pro Max" - case "iPhone12,8": - return "iPhone SE (2nd Gen)" + case "iPhone12,1": + return "iPhone 11" + case "iPhone12,5": + return "iPhone 11 Pro Max" + case "iPhone12,8": + return "iPhone SE (2nd Gen)" - case "iPhone13,1": - return "iPhone 12 mini" - case "iPhone13,2": - return "iPhone 12" - case "iPhone13,3": - return "iPhone 12 Pro" - case "iPhone13,4": - return "iPhone 12 Pro Max" + case "iPhone13,1": + return "iPhone 12 mini" + case "iPhone13,2": + return "iPhone 12" + case "iPhone13,3": + return "iPhone 12 Pro" + case "iPhone13,4": + return "iPhone 12 Pro Max" - case "iPhone14,2": - return "iPhone 13 Pro" - case "iPhone14,3": - return "iPhone 13 Pro Max" - case "iPhone14,4": - return "iPhone 13 mini" - case "iPhone14,5": - return "iPhone 13" - case "iPhone14,6": - return "iPhone SE (3rd Gen)" - case "iPhone14,7": - return "iPhone 14" - case "iPhone14,8": - return "iPhone 14 Plus" + case "iPhone14,2": + return "iPhone 13 Pro" + case "iPhone14,3": + return "iPhone 13 Pro Max" + case "iPhone14,4": + return "iPhone 13 mini" + case "iPhone14,5": + return "iPhone 13" + case "iPhone14,6": + return "iPhone SE (3rd Gen)" + case "iPhone14,7": + return "iPhone 14" + case "iPhone14,8": + return "iPhone 14 Plus" - case "iPhone15,2": - return "iPhone 14 Pro" - case "iPhone15,3": - return "iPhone 14 Pro Max" + case "iPhone15,2": + return "iPhone 14 Pro" + case "iPhone15,3": + return "iPhone 14 Pro Max" - default: - return identifier + default: + return identifier + } } + + return mapToDevice(identifier: identifier) } - return mapToDevice(identifier: identifier) - } + var getOSInfo: String { + let os = ProcessInfo.processInfo.operatingSystemVersion + return String(os.majorVersion) + "." + String(os.minorVersion) + "." + String(os.patchVersion) + } - var getOSInfo: String { - let os = ProcessInfo.processInfo.operatingSystemVersion - return String(os.majorVersion) + "." + String(os.minorVersion) + "." + String(os.patchVersion) + public enum DeviceSize: CGFloat { + case smallDevice = 667 // Height for 4" iPhone SE + case largeDevice = 852 // Height for 6.1" iPhone 15 Pro + } + + public static func adjustPadding( + min: CGFloat? = nil, + max: CGFloat? = nil + ) -> CGFloat? { + if UIScreen.main.bounds.height > UIDevice.DeviceSize.smallDevice.rawValue { + if UIScreen.main.bounds.height >= UIDevice.DeviceSize.largeDevice.rawValue { + return max + } else { + return min != nil ? + (max != nil ? max! * (UIScreen.main.bounds.height / UIDevice.DeviceSize.largeDevice.rawValue) : nil) : nil + } + } else { + return min + } + } } -} +#endif diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 0754b119aef..0c3167b55e2 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -16673,6 +16673,9 @@ } } } + }, + "• Medtrum Nano (200u/300u)" : { + }, "• Nightscout" : { "localizations" : { @@ -20236,6 +20239,9 @@ "comment" : "Name of the bolus display threshold option that represents 0.5 units.", "isCommentAutoGenerated" : true }, + "1 hour" : { + "comment" : "Snooze glucose alerts for 1 hour" + }, "1 U and over" : { "comment" : "Name to display for the \"1 U and over\" bolus display threshold option.", "isCommentAutoGenerated" : true @@ -20719,6 +20725,9 @@ } } }, + "3 hours" : { + "comment" : "Snooze glucose alerts for 3 hours" + }, "3 M" : { "comment" : "Abbreviation for three months", "localizations" : { @@ -22157,6 +22166,9 @@ } } }, + "20 min" : { + "comment" : "Snooze glucose alerts for 20 minutes" + }, "24 hours" : { "extractionState" : "manual", "localizations" : { @@ -162694,6 +162706,9 @@ } } } + }, + "Medtrum Nano" : { + }, "Menu" : { "comment" : "Menu", @@ -265731,120 +265746,50 @@ } } }, - "Very Low (<%@" : { + "Very Low (<%@)" : { + "comment" : "A description of a glucose range.", + "isCommentAutoGenerated" : true, "localizations" : { - "bg" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, "da" : { "stringUnit" : { "state" : "translated", - "value" : "Meget Lav (<%@" + "value" : "Meget Lav (<%@)" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Sehr niedrig (<%@" - } - }, - "es" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" + "value" : "Sehr niedrig (<%@)" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Très bas (<%@" - } - }, - "he" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, - "it" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" + "value" : "Très bas (<%@)" } }, "nb-NO" : { "stringUnit" : { "state" : "translated", - "value" : "Veldig lavt (<%@" - } - }, - "nl" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" + "value" : "Veldig lavt (<%@)" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Bardzo niski (<%@" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, - "ro" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, - "sv" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" - } - }, - "uk" : { - "stringUnit" : { - "state" : "new", - "value" : "Very Low (<%@" + "value" : "Bardzo niski (<%@)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Qua thấp (<%@" + "value" : "Qua thấp (<%@)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "非常低(<%@" + "value" : "非常低(<%@)" } } } diff --git a/Trio/Sources/Models/NotificationIdentifiers.swift b/Trio/Sources/Models/NotificationIdentifiers.swift new file mode 100644 index 00000000000..335c28096b3 --- /dev/null +++ b/Trio/Sources/Models/NotificationIdentifiers.swift @@ -0,0 +1,64 @@ +import Foundation +import UserNotifications + +enum NotificationCategoryIdentifier: String { + case trioAlert = "Trio.alert" +} + +enum NotificationResponseAction: String, CaseIterable { + case snooze20 = "Trio.snooze20" + case snooze1hr = "Trio.snooze1hr" + case snooze3hr = "Trio.snooze3hr" + case snooze6hr = "Trio.snooze6hr" + + var duration: TimeInterval { + TimeInterval(minutes) * 60 + } + + var minutes: Int { + switch self { + case .snooze20: + return 20 + case .snooze1hr: + return 60 + case .snooze3hr: + return 180 + case .snooze6hr: + return 360 + } + } + + var localizedTitle: String { + switch self { + case .snooze20: + return String(localized: "20 min", comment: "Snooze glucose alerts for 20 minutes") + case .snooze1hr: + return String(localized: "1 hour", comment: "Snooze glucose alerts for 1 hour") + case .snooze3hr: + return String(localized: "3 hours", comment: "Snooze glucose alerts for 3 hours") + case .snooze6hr: + return String(localized: "6 hours", comment: "Snooze glucose alerts for 6 hours") + } + } +} + +// MARK: - NotificationCategoryFactory + +enum NotificationCategoryFactory { + static func createGlucoseCategory() -> UNNotificationCategory { + let snoozeActions = NotificationResponseAction.allCases.map { action in + UNNotificationAction( + identifier: action.rawValue, + title: action.localizedTitle, + options: [] + ) + } + + return UNNotificationCategory( + identifier: NotificationCategoryIdentifier.trioAlert.rawValue, + actions: snoozeActions, + intentIdentifiers: [], + options: [] + ) + } +} diff --git a/Trio/Sources/Models/NutritionData.swift b/Trio/Sources/Models/NutritionData.swift new file mode 100644 index 00000000000..fb40eb16d1b --- /dev/null +++ b/Trio/Sources/Models/NutritionData.swift @@ -0,0 +1,50 @@ +import Foundation +import UIKit + +// MARK: - Nutrition Data + +extension BarcodeScanner { + /// Represents extracted nutrition data from a label + struct NutritionData: Equatable { + var calories: Double? + var carbohydrates: Double? + var sugars: Double? + var fat: Double? + var saturatedFat: Double? + var protein: Double? + var fiber: Double? + var sodium: Double? + var servingSize: String? + var servingSizeGrams: Double? + + var hasAnyData: Bool { + calories != nil || carbohydrates != nil || fat != nil || protein != nil + } + + /// Converts scanned nutrition data to a unified FoodItem + func toProduct(name: String = "Scanned Label", basisAmount: Double = 100.0, capturedImage: UIImage? = nil) -> FoodItem { + let factor = basisAmount > 0 ? (100.0 / basisAmount) : 1.0 + + var imageSource: FoodItem.ImageSource = .none + if let image = capturedImage { + imageSource = .image(image) + } + + return FoodItem( + name: name, + imageSource: imageSource, + nutriments: .init( + basis: .per100g, + energyKcalPer100g: calories.map { $0 * factor }, + carbohydratesPer100g: carbohydrates.map { $0 * factor }, + sugarsPer100g: sugars.map { $0 * factor }, + fatPer100g: fat.map { $0 * factor }, + proteinPer100g: protein.map { $0 * factor }, + fiberPer100g: fiber.map { $0 * factor } + ), + amount: servingSizeGrams ?? 100, + isManualEntry: true + ) + } + } +} diff --git a/Trio/Sources/Models/OpenFoodFactsModels.swift b/Trio/Sources/Models/OpenFoodFactsModels.swift new file mode 100644 index 00000000000..98dd1f7daa0 --- /dev/null +++ b/Trio/Sources/Models/OpenFoodFactsModels.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftUI +import UIKit + +// MARK: - Food Item Model + +extension BarcodeScanner { + /// A unified food item model representing both scanned barcodes (API) and camera-scanned labels + struct FoodItem: Identifiable, Equatable { + // MARK: - Subtypes + + struct Nutriments: Equatable { + enum Basis: Equatable { + case per100g + case per100ml + } + + var basis: Basis + var energyKcalPer100g: Double? + var carbohydratesPer100g: Double? + var sugarsPer100g: Double? + var fatPer100g: Double? + var proteinPer100g: Double? + var fiberPer100g: Double? + } + + enum ImageSource: Equatable { + case url(URL) + case image(UIImage) + case none + + static func == (lhs: ImageSource, rhs: ImageSource) -> Bool { + switch (lhs, rhs) { + case let (.url(u1), .url(u2)): return u1 == u2 + case let (.image(i1), .image(i2)): return i1 === i2 + case (.none, .none): return true + default: return false + } + } + } + + // MARK: - Properties + + let id: UUID + let barcode: String? + let name: String + let brand: String? + let quantity: String? + let servingSize: String? + let ingredients: String? + + /// Preferred unit for user input (true = ml, false = g) + let defaultPortionIsMl: Bool + var servingQuantity: Double? + var servingQuantityUnit: String? + + var nutriments: Nutriments + + // User-editable state + var amount: Double + var isMlInput: Bool + var isManualEntry: Bool + var imageSource: ImageSource + + // MARK: - Initialization + + init( + id: UUID = UUID(), + barcode: String? = nil, + name: String, + brand: String? = nil, + quantity: String? = nil, + servingSize: String? = nil, + ingredients: String? = nil, + imageSource: ImageSource = .none, + defaultPortionIsMl: Bool = false, + servingQuantity: Double? = nil, + servingQuantityUnit: String? = nil, + nutriments: Nutriments, + amount: Double = 0, + isMlInput: Bool = false, + isManualEntry: Bool = false + ) { + self.id = id + self.barcode = barcode + self.name = name + self.brand = brand + self.quantity = quantity + self.servingSize = servingSize + self.ingredients = ingredients + self.imageSource = imageSource + self.defaultPortionIsMl = defaultPortionIsMl + self.servingQuantity = servingQuantity + self.servingQuantityUnit = servingQuantityUnit + self.nutriments = nutriments + self.amount = amount + self.isMlInput = isMlInput + self.isManualEntry = isManualEntry + } + } +} diff --git a/Trio/Sources/Models/TrioSettings.swift b/Trio/Sources/Models/TrioSettings.swift index b4feadee885..13e792710fe 100644 --- a/Trio/Sources/Models/TrioSettings.swift +++ b/Trio/Sources/Models/TrioSettings.swift @@ -71,6 +71,8 @@ struct TrioSettings: JSON, Equatable { var smartStackView: LockScreenView = .simple var bolusShortcut: BolusShortcutLimit = .notAllowed var timeInRangeType: TimeInRangeType = .timeInTightRange + var barcodeScannerEnabled: Bool = false + var barcodeScannerOnlyCarbs: Bool = false } extension TrioSettings: Decodable { @@ -305,6 +307,14 @@ extension TrioSettings: Decodable { settings.timeInRangeType = timeInRangeType } + if let barcodeScannerEnabled = try? container.decode(Bool.self, forKey: .barcodeScannerEnabled) { + settings.barcodeScannerEnabled = barcodeScannerEnabled + } + + if let barcodeScannerOnlyCarbs = try? container.decode(Bool.self, forKey: .barcodeScannerOnlyCarbs) { + settings.barcodeScannerOnlyCarbs = barcodeScannerOnlyCarbs + } + self = settings } } diff --git a/Trio/Sources/Models/WatchMessageKeys.swift b/Trio/Sources/Models/WatchMessageKeys.swift index b7b754bf096..68e4bdf391e 100644 --- a/Trio/Sources/Models/WatchMessageKeys.swift +++ b/Trio/Sources/Models/WatchMessageKeys.swift @@ -51,4 +51,7 @@ enum WatchMessageKeys { static let maxProtein = "maxProtein" static let bolusIncrement = "bolusIncrement" static let confirmBolusFaster = "confirmBolusFaster" + + // Notification Actions + static let snoozeDuration = "snoozeDuration" } diff --git a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift index b8234bea55c..ba92435b3ac 100644 --- a/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift +++ b/Trio/Sources/Modules/Adjustments/View/AdjustmentsRootView.swift @@ -66,18 +66,6 @@ extension Adjustments { .background(appState.trioBackgroundColor(for: colorScheme)) } .listSectionSpacing(10) - .safeAreaInset( - edge: .bottom, - spacing: shouldDisplayStickyOverrideStopButton || shouldDisplayStickyTempTargetStopButton ? 30 : 0 - ) { - if shouldDisplayStickyOverrideStopButton, state.selectedTab == .overrides { - stickyStopOverrideButton - } else if shouldDisplayStickyTempTargetStopButton, state.selectedTab == .tempTargets { - stickyStopTempTargetButton - } else { - EmptyView() - } - } .scrollContentBackground(.hidden) .background(appState.trioBackgroundColor(for: colorScheme)) .onAppear(perform: configureView) @@ -248,6 +236,41 @@ extension Adjustments { } } + @ViewBuilder var stopAdjustmentButton: some View { + switch state.selectedTab { + case .overrides: + Section { + Button(action: { + showCancelOverrideConfirmDialog = true + }, label: { + Text("Stop Override") + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.red) + .clipShape(RoundedRectangle(cornerRadius: 10)) + }) + .buttonStyle(.plain) + } + .listRowBackground(Color.clear) + case .tempTargets: + Section { + Button(action: { + showCancelTempTargetConfirmDialog = true + }, label: { + Text("Stop Temp Target") + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.red) + .clipShape(RoundedRectangle(cornerRadius: 10)) + }) + .buttonStyle(.plain) + } + .listRowBackground(Color.clear) + } + } + var cancelAdjustmentButton: some View { switch state.selectedTab { case .overrides: diff --git a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift index 19fd8ac4313..7bc8fa5cf90 100644 --- a/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/View/Overrides/AdjustmentsRootView+Overrides.swift @@ -5,6 +5,7 @@ extension Adjustments.RootView { @ViewBuilder func overrides() -> some View { if state.isOverrideEnabled, state.activeOverrideName.isNotEmpty { currentActiveAdjustment + stopAdjustmentButton } if state.overridePresets.isNotEmpty { overridePresets diff --git a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift index 2e5a52dc175..1e9e27c984d 100644 --- a/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift @@ -5,6 +5,7 @@ extension Adjustments.RootView { @ViewBuilder func tempTargets() -> some View { if state.isTempTargetEnabled, state.activeTempTargetName.isNotEmpty { currentActiveAdjustment + stopAdjustmentButton } if state.scheduledTempTargets.isNotEmpty { scheduledTempTargets diff --git a/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerDataFlow.swift b/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerDataFlow.swift new file mode 100644 index 00000000000..2674c071793 --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerDataFlow.swift @@ -0,0 +1,7 @@ +/// BarcodeScanner module for scanning barcodes and nutrition labels +enum BarcodeScanner { + enum Config {} +} + +/// Provider protocol for BarcodeScanner module +protocol BarcodeScannerProvider: Provider {} diff --git a/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerProvider.swift b/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerProvider.swift new file mode 100644 index 00000000000..2ea397b6891 --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerProvider.swift @@ -0,0 +1,3 @@ +extension BarcodeScanner { + final class Provider: BaseProvider, BarcodeScannerProvider {} +} diff --git a/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerStateModel.swift b/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerStateModel.swift new file mode 100644 index 00000000000..4dfcd99b6d5 --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/BarcodeScannerStateModel.swift @@ -0,0 +1,314 @@ +import AVFoundation +import Foundation +import Observation +import SwiftUI + +// MARK: - StateModel + +extension BarcodeScanner { + final class StateModel: BaseStateModel { + // MARK: - Properties + + @Published var cameraStatus: AVAuthorizationStatus = AVCaptureDevice.authorizationStatus( + for: .video + ) + @Published var isScanning = true + @Published var isKeyboardVisible = false + @Published var currentScannedItem: FoodItem? + @Published var isFetchingProduct = false + @Published var errorMessage: String? + @Published var scannedProducts: [FoodItem] = [] + @Published var isEditingFromList: Bool = false + + @Published var scannedLabelBasisAmount: Double = 100.0 + + // External control + @Published var showListView = false + var onAddTreatments: ((Decimal, Decimal, Decimal, String) -> Void)? + var onDismiss: (() -> Void)? + + // Editor amount input + @Published var editingAmount: Double = 0 + @Published var editingIsMl: Bool = false + + // Search State + @Published var searchQuery = "" + @Published var searchResults: [FoodItem] = [] + @Published var isSearching = false + @Published var searchError: String? + + // MARK: - Private Properties + + private let client = OpenFoodFactsClient() + private var lastScanTime: Date? + private var lastScannedBarcode: String? + private var lastScanWasSuccessful: Bool = false + private let scanCooldownSeconds: TimeInterval = 1.0 + + // MARK: - Lifecycle + + func handleAppear() { + refreshCameraStatus() + + switch cameraStatus { + case .notDetermined: + requestCameraAccess() + case .authorized: + isScanning = true + default: + isScanning = false + errorMessage = String(localized: "Camera access is required to scan barcodes.") + } + } + + // MARK: - Camera Access + + func refreshCameraStatus() { + cameraStatus = AVCaptureDevice.authorizationStatus(for: .video) + } + + private func requestCameraAccess() { + AVCaptureDevice.requestAccess(for: .video) { granted in + Task { @MainActor in + self.refreshCameraStatus() + if granted { + self.errorMessage = nil + self.isScanning = true + } else { + self.isScanning = false + self.showTemporaryError( + String( + localized: "Camera permissions were denied. Enable them in Settings to continue." + ) + ) + } + } + } + } + + func openAppSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + + // MARK: - Barcode Scanning + + func reportScannerIssue(_ message: String) { + showTemporaryError(message) + isScanning = false + } + + func scanAgain(resetResults: Bool = false) { + guard cameraStatus == .authorized else { return } + if resetResults { + currentScannedItem = nil + errorMessage = nil + scannedProducts.removeAll() + lastScanTime = nil + lastScannedBarcode = nil + lastScanWasSuccessful = false + } + isScanning = true + } + + func didDetect(barcode: String) { + Task { @MainActor in + // Prevent rapid scanning - require cooldown between scans + if let lastScan = lastScanTime, Date().timeIntervalSince(lastScan) < scanCooldownSeconds { + return + } + + // Prevent rescanning the same barcode (valid or invalid) + guard barcode != lastScannedBarcode else { return } + + lastScannedBarcode = barcode + lastScanTime = Date() + fetchProduct(for: barcode) + } + } + + private func fetchProduct(for barcode: String) { + isFetchingProduct = true + errorMessage = nil + + Task { @MainActor in + do { + var fetchedProduct = try await client.fetchProduct(barcode: barcode) + self.setupEditingAmount(for: fetchedProduct) + + // Pre-fill amount in the item for display, though editingAmount controls input + fetchedProduct.amount = self.editingAmount + fetchedProduct.isMlInput = self.editingIsMl + + self.currentScannedItem = fetchedProduct + self.lastScanWasSuccessful = true + self.isFetchingProduct = false + } catch { + guard !Task.isCancelled else { return } + self.currentScannedItem = nil + self.lastScanWasSuccessful = false + self.isFetchingProduct = false + self.showTemporaryError( + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + ) + } + } + } + + /// Shows a transient error message that auto-clears after a short delay + private func showTemporaryError(_ message: String, duration: TimeInterval = 3) { + errorMessage = message + Task { @MainActor in + try? await Task.sleep(for: .seconds(duration)) + // Only clear if no new error was set in the meantime + if self.errorMessage == message { + self.errorMessage = nil + } + } + } + + // MARK: - Product Management + + func removeScannedProduct(_ item: FoodItem) { + scannedProducts.removeAll { $0.id == item.id } + } + + func updateScannedProductAmount(_ item: FoodItem, amount: Double, isMlInput: Bool) { + if let index = scannedProducts.firstIndex(where: { $0.id == item.id }) { + scannedProducts[index].amount = amount + scannedProducts[index].isMlInput = isMlInput + } + } + + func editScannedProduct(_ item: FoodItem) { + // Set as current item for editing + currentScannedItem = item + + // Set up editing state + editingAmount = item.amount + editingIsMl = item.isMlInput + + // Stop scanning while editing + isScanning = false + } + + /// Updates a nutriment value for the currently displayed product + func updateProductNutriment( + keyPath: WritableKeyPath, + value: Double? + ) { + currentScannedItem?.nutriments[keyPath: keyPath] = value + } + + /// Adds the currently displayed product (with edited nutriments) to the list + func addProductToList() { + guard var item = currentScannedItem else { return } + + // Update with latest user edits + item.amount = editingAmount + item.isMlInput = editingIsMl + + // If "Only Carbs" setting is on, ensure other macros are zeroed out + if settingsManager.settings.barcodeScannerOnlyCarbs { + item.nutriments.fatPer100g = 0 + item.nutriments.proteinPer100g = 0 + } + + if let index = scannedProducts.firstIndex(where: { $0.id == item.id }) { + scannedProducts[index] = item + } else { + scannedProducts.append(item) + } + + // Clear the editor and resume scanning + clearScannedProduct() + + // Automatically switch to list view after adding + showListView = true + } + + /// Sets up editing state when a product is loaded + func setupEditingAmount(for product: FoodItem) { + // Determine initial amount and unit from serving info + editingAmount = product.servingQuantity ?? 100 + if let servingUnit = product.servingQuantityUnit?.lowercased() { + editingIsMl = + servingUnit.contains("ml") || servingUnit == "l" || servingUnit.contains("fl oz") + } else { + editingIsMl = product.defaultPortionIsMl + } + } + + func selectQuickPortion(amount: Double, unit: String) { + if editingAmount == amount { + // Deselect: revert to standard 100 basis + editingAmount = 100 + currentScannedItem?.servingQuantity = nil + currentScannedItem?.servingQuantityUnit = nil + } else { + editingAmount = amount + currentScannedItem?.servingQuantity = amount + currentScannedItem?.servingQuantityUnit = unit + } + } + + /// Clears the currently displayed product from the overlay + func clearScannedProduct() { + currentScannedItem = nil + lastScannedBarcode = nil + lastScanWasSuccessful = false + errorMessage = nil + isScanning = true + } + + /// Whether to show the editor view (product available) + var showEditorView: Bool { + currentScannedItem != nil + } + + /// Cancels the current editing session and returns to scanner + func cancelEditing() { + // Clear all editing state (product was not added to list yet) + currentScannedItem = nil + lastScannedBarcode = nil + lastScanWasSuccessful = false + errorMessage = nil + editingAmount = 0 + editingIsMl = false + isScanning = true + } + + /// Performs the dismissal of the barcode scanner module + func performDismissal() { + if let onDismiss = onDismiss { + onDismiss() + } else { + hideModal() + } + } + + /// Performs food search using Open Food Facts API + func performFoodSearch() { + searchError = nil + searchResults = [] + + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { + isSearching = false + return + } + + isSearching = true + + Task { @MainActor in + do { + searchResults = try await client.searchProducts(query: query) + } catch { + searchError = error.localizedDescription + searchResults = [] + } + isSearching = false + } + } + } +} diff --git a/Trio/Sources/Modules/BarcodeScanner/View/BarcodeScannerRootView.swift b/Trio/Sources/Modules/BarcodeScanner/View/BarcodeScannerRootView.swift new file mode 100644 index 00000000000..a11809eb3ba --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/View/BarcodeScannerRootView.swift @@ -0,0 +1,570 @@ +import SwiftUI +import Swinject + +// MARK: - Root View + +extension BarcodeScanner { + struct RootView: BaseView { + let resolver: Resolver + var showListInitially: Bool = false + var onAddTreatments: ((Decimal, Decimal, Decimal, String) -> Void)? + + @ObservedObject var state: StateModel + @State private var isEditingFromList = false + @State private var showEditorCard = false + @FocusState private var focusedItemID: UUID? + @FocusState private var isSearchFocused: Bool + @State private var showAllSearchResults = false + + init( + resolver: Resolver, + state: StateModel, + showListInitially: Bool = false, + onAddTreatments: ((Decimal, Decimal, Decimal, String) -> Void)? = nil, + onDismiss: (() -> Void)? = nil + ) { + self.resolver = resolver + _state = ObservedObject(wrappedValue: state) + self.showListInitially = showListInitially + self.onAddTreatments = onAddTreatments + // Wire optional callback into the state so it can call back when user selects "Add to Treatments" + self.state.onAddTreatments = onAddTreatments + self.state.onDismiss = onDismiss + } + + @Environment(AppState.self) var appState + @Environment(\.colorScheme) var colorScheme + + enum NutritionField: Hashable { + case name + case amount + case calories + case carbs + case sugars + case fat + case protein + case fiber + } + + var body: some View { + ZStack { + if state.showListView { + listViewContent + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + scannerViewContent + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + .simultaneousGesture( + DragGesture() + .onEnded { value in + if abs(value.translation.width) > abs(value.translation.height) { + // Horizontal Swipe - Switch Views + // In List View, require Edge Swipe (from left) to avoid conflict with row actions + let isEdgeSwipe = value.startLocation.x < 50 + // Higher threshold to distinguish from accidental diagonal drags + let threshold: CGFloat = 80 + + if value.translation.width > threshold { + // Swipe Right (Back to Scanner) + // Only allow if (Scanner Mode) OR (List Mode AND Edge Swipe) + if !state.showListView || isEdgeSwipe { + state.showListView = false + } + } else if value.translation.width < -threshold { + // Swipe Left (Go to List) + state.showListView = true + } + } + } + ) + .animation(.easeInOut(duration: 0.3), value: state.showListView) + .background(appState.trioBackgroundColor(for: colorScheme).ignoresSafeArea()) + .navigationTitle(String(localized: state.showListView ? "Scanned Items" : "Barcode Scanner")) + .navigationBarTitleDisplayMode(.inline) + .toolbar(content: { + ToolbarItem(placement: .topBarLeading) { + if state.showEditorView { + Button( + action: { + state.cancelEditing() + }, + label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text(String(localized: "Back")) + .fixedSize(horizontal: true, vertical: false) + } + } + ) + .buttonStyle(BorderlessButtonStyle()) + } else { + Button( + action: { + state.performDismissal() + }, + label: { + Text(String(localized: "Close")) + .fixedSize(horizontal: true, vertical: false) + } + ) + .buttonStyle(BorderlessButtonStyle()) + } + } + ToolbarItem(placement: .topBarTrailing) { + // Hide the item list button while editing nutrition details + if state.showListView { + Button( + action: { + state.showListView = false + }, + label: { + Image(systemName: "barcode.viewfinder") + .font(.body) + } + ) + } else if !state.showEditorView { + Button( + action: { + state.showListView = true + }, + label: { + HStack { + ZStack(alignment: .topTrailing) { + Image(systemName: "list.bullet") + .font(.body) + if !state.scannedProducts.isEmpty { + Text("\(state.scannedProducts.count)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(4) + .background(Circle().fill(Color.red)) + .offset(x: 8, y: -8) + } + } + } + } + ) + .buttonStyle(BorderlessButtonStyle()) + } + } + }) + .sheet(isPresented: $showEditorCard) { + NavigationStack { + NutritionEditorView( + state: state, + isEditingFromList: $isEditingFromList, + onDismissList: { showEditorCard = false } + ) + .navigationTitle(String(localized: "Edit Item")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(String(localized: "Cancel")) { + showEditorCard = false + // Robust cleanup: Check either local or state flag + if isEditingFromList || state.isEditingFromList { + isEditingFromList = false + state.isEditingFromList = false + state.cancelEditing() + } + } + } + } + } + } + .onChange(of: showEditorCard) { _, isPresented in + // If the sheet is dismissed interactively while editing from list, reset editing state + if !isPresented { + if isEditingFromList || state.isEditingFromList { + isEditingFromList = false + state.isEditingFromList = false + state.cancelEditing() + } + } + } + .onAppear { + configureView() + state.handleAppear() + state.showListView = showListInitially + } + } + + // MARK: - Scanner View Content + + private var scannerViewContent: some View { + Group { + if state.showEditorView { + // Show full editor view when product/nutrition data is available + NutritionEditorView( + state: state, + isEditingFromList: $isEditingFromList, + onDismissList: { state.showListView = true } + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + GeometryReader { geo in + ScrollView { + ZStack { + if state.isFetchingProduct { + // Loading state + loadingView + .transition(.opacity) + } else { + // Scanner view + fullScreenCameraView + .transition(.move(edge: .leading).combined(with: .opacity)) + } + + // Error overlay (always visible if there's an error) + if let message = state.errorMessage { + VStack { + Spacer() + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.orange) + .padding(12) + .background(Color.orange.opacity(0.12)) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + .padding(.bottom, 100) + } + .allowsHitTesting(false) + } + } + .frame(minHeight: geo.size.height) + } + .scrollIndicators(.hidden) + } + } + } + .onChange(of: focusedItemID) { _, newValue in + if newValue != nil { + state.isKeyboardVisible = true + state.isScanning = false + } else { + state.isKeyboardVisible = false + } + } + } + + // MARK: - Loading View + + private var loadingView: some View { + VStack(spacing: 16) { + Spacer() + ProgressView() + .scaleEffect(1.5) + Text( + String(localized: "Looking up product…") + ) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Full Screen Camera View + + private var fullScreenCameraView: some View { + ZStack { + switch state.cameraStatus { + case .authorized: + ZStack { + ScannerPreviewView( + isRunning: Binding( + get: { state.isScanning }, + set: { state.isScanning = $0 } + ), + onDetected: { state.didDetect(barcode: $0) }, + onFailure: state.reportScannerIssue + ) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(.white.opacity(0.3), lineWidth: 1) + ) + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 120) + + // Action buttons at bottom + VStack { + Spacer() + cameraActionButtons + } + } + + case .notDetermined: + VStack { + Spacer() + ProgressView(String(localized: "Requesting camera access…")) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + + default: + VStack(spacing: 16) { + Spacer() + Image(systemName: "camera.fill") + .font(.system(size: 50)) + .foregroundStyle(.secondary) + Label( + String(localized: "Enable camera access to start scanning."), + systemImage: "lock.shield" + ) + .font(.subheadline) + Button(String(localized: "Open Settings"), action: state.openAppSettings) + .buttonStyle(.borderedProminent) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.9)) + } + } + } + + // MARK: - Camera Action Buttons + + private var cameraActionButtons: some View { + HStack(spacing: 12) { + Button { + if state.isScanning { + state.isScanning = false + } else { + state.scanAgain(resetResults: false) + } + } label: { + HStack(spacing: 6) { + Image(systemName: state.isScanning ? "pause.fill" : "barcode.viewfinder") + Text(state.isScanning ? "Pause" : "Scan") + } + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(state.isScanning ? .orange : .insulin) + + if !state.scannedProducts.isEmpty { + // "Calculator" button removed as per request for live updates + } + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 16) + } + + // MARK: - List View Content + + private var listViewContent: some View { + ZStack(alignment: .leading) { + List { + // Search Section + Section { + BarcodeScanner.ProductSearchField( + searchText: $state.searchQuery, + isFocused: $isSearchFocused, + onSubmit: { + showAllSearchResults = false + state.performFoodSearch() + }, + onClear: { + state.searchQuery = "" + state.searchResults = [] + showAllSearchResults = false + }, + onChange: { + showAllSearchResults = false + } + ) + .padding(.horizontal) + .listRowInsets(EdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + if state.isSearching { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 8) + Spacer() + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else if let error = state.searchError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else if !state.searchResults.isEmpty { + let displayResults = + showAllSearchResults ? state.searchResults : Array(state.searchResults.prefix(5)) + ForEach(displayResults) { item in + BarcodeScanner.FoodSearchResultRow(item: item) { + withAnimation { + var mutableItem = item + mutableItem.amount = item.servingQuantity ?? 100 + state.scannedProducts.append(mutableItem) + state.searchQuery = "" + state.searchResults = [] + isSearchFocused = false + } + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + } + + if state.searchResults.count > 5 { + Button { + withAnimation { + showAllSearchResults.toggle() + } + } label: { + HStack { + Text( + showAllSearchResults + ? "Show less" : "Show \(state.searchResults.count - 5) more results" + ) + .font(.caption.weight(.medium)) + Image(systemName: showAllSearchResults ? "chevron.up" : "chevron.down") + .font(.caption) + } + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + } + + if state.scannedProducts.isEmpty, state.searchResults.isEmpty, !state.isSearching { + emptyListView + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + } + + if !state.scannedProducts.isEmpty { + Section { + listHeader + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 16, leading: 16, bottom: 8, trailing: 16)) + } + + Section { + ForEach(state.scannedProducts) { item in + ScannedProductRow(item: item, state: state, focusedItemID: $focusedItemID) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + withAnimation { + state.removeScannedProduct(item) + } + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + state.editScannedProduct(item) + isEditingFromList = true + state.isEditingFromList = true + showEditorCard = true + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + } + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + + // Edge Swipe Overlay: Invisible touch zone on the left edge + // Captures swipes to go back to scanner, preventing conflict with list row swipes + Color.clear + .contentShape(Rectangle()) + .frame(width: 30) + .frame(maxHeight: .infinity) + } + } + + private var emptyListView: some View { + VStack(spacing: 20) { + Spacer() + Image(systemName: "barcode.viewfinder") + .font(.system(size: 60)) + .foregroundStyle(.secondary) + Text(String(localized: "No items scanned yet")) + .font(.title3.weight(.medium)) + Text(String(localized: "Scan barcodes or search to add items.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + Button { + state.showListView = false + } label: { + HStack { + Image(systemName: "barcode.viewfinder") + Text(String(localized: "Start Scanning")) + } + .font(.subheadline.weight(.semibold)) + .padding(.horizontal) + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + private var listHeader: some View { + let totalCarbs = state.scannedProducts.reduce(into: 0.0) { result, item in + let carbsPer100 = item.nutriments.carbohydratesPer100g ?? 0 + let amount = item.amount.isFinite ? item.amount : 0 + result += (carbsPer100 * amount) / 100.0 + } + let totalCalories = state.scannedProducts.reduce(into: 0.0) { result, item in + let kcalPer100 = item.nutriments.energyKcalPer100g ?? 0 + let amount = item.amount.isFinite ? item.amount : 0 + result += (kcalPer100 * amount) / 100.0 + } + + return VStack(alignment: .leading, spacing: 8) { + Text( + "\(state.scannedProducts.count) Item\(state.scannedProducts.count == 1 ? "" : "s") Scanned" + ) + .font(.title2) + .bold() + + HStack(spacing: 16) { + Text("total \(totalCarbs, specifier: "%.1f") g of carbs") + .foregroundStyle(.blue) + } + .font(.subheadline) + } + } + + // MARK: - Helper Functions + } +} diff --git a/Trio/Sources/Modules/BarcodeScanner/View/FoodSearchResultRow.swift b/Trio/Sources/Modules/BarcodeScanner/View/FoodSearchResultRow.swift new file mode 100644 index 00000000000..c1f8566dbab --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/View/FoodSearchResultRow.swift @@ -0,0 +1,88 @@ +import SwiftUI + +extension BarcodeScanner { + /// A compact row view for displaying food search results + struct FoodSearchResultRow: View { + let item: BarcodeScanner.FoodItem + let onAdd: () -> Void + + var body: some View { + Button(action: onAdd) { + HStack(spacing: 12) { + // Product image + productImage + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Product info + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + + HStack(spacing: 8) { + if let brand = item.brand { + Text(brand) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let carbs = item.nutriments.carbohydratesPer100g { + Text("\(carbs, specifier: "%.1f")g carbs/100g") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + + Spacer() + + // Add button indicator + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(.blue) + } + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } + + @ViewBuilder private var productImage: some View { + switch item.imageSource { + case let .url(url): + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFill() + case .failure: + imagePlaceholder + default: + ProgressView() + .frame(width: 44, height: 44) + } + } + + case let .image(uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + + case .none: + imagePlaceholder + } + } + + private var imagePlaceholder: some View { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.2)) + .overlay( + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(.secondary) + ) + } + } +} diff --git a/Trio/Sources/Modules/BarcodeScanner/View/NutritionEditorView.swift b/Trio/Sources/Modules/BarcodeScanner/View/NutritionEditorView.swift new file mode 100644 index 00000000000..4af3a043a33 --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/View/NutritionEditorView.swift @@ -0,0 +1,272 @@ +import SwiftUI + +// MARK: - Nutrition Editor View + +extension BarcodeScanner { + struct NutritionEditorView: View { + @ObservedObject var state: StateModel + @FocusState private var focusedField: RootView.NutritionField? + @Binding var isEditingFromList: Bool + var onDismissList: () -> Void + + @Environment(AppState.self) var appState + @Environment(\.colorScheme) var colorScheme + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let product = state.currentScannedItem { + // Product header + HStack(alignment: .top, spacing: 12) { + switch product.imageSource { + case let .url(url): + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFill() + case .failure: + productPlaceholder + default: + ProgressView() + } + } + .frame(width: 70, height: 70) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + case let .image(uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(width: 70, height: 70) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + case .none: + productPlaceholder + .frame(width: 70, height: 70) + } + + VStack(alignment: .leading, spacing: 4) { + Text(product.name) + .font(.headline) + .lineLimit(2) + if let brand = product.brand { + Text(brand) + .font(.subheadline) + .foregroundStyle(.secondary) + } + if let quantity = product.quantity { + Text(quantity) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + + Text("Nutrition (per 100g)") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, 8) + + // Editable nutrition rows for product + VStack(spacing: 0) { + NutritionTextField( + label: String(localized: "Carbohydrates"), + value: Binding( + get: { state.currentScannedItem?.nutriments.carbohydratesPer100g ?? 0 }, + set: { + state.updateProductNutriment(keyPath: \.carbohydratesPer100g, value: $0) + } + ), + unit: "g", + field: .carbs, + focusedField: $focusedField + ) + if !state.settingsManager.settings.barcodeScannerOnlyCarbs { + Divider().padding(.leading) + + NutritionTextField( + label: String(localized: "Fat"), + value: Binding( + get: { state.currentScannedItem?.nutriments.fatPer100g ?? 0 }, + set: { state.updateProductNutriment(keyPath: \.fatPer100g, value: $0) } + ), + unit: "g", + field: .fat, + focusedField: $focusedField + ) + + Divider().padding(.leading) + + NutritionTextField( + label: String(localized: "Protein"), + value: Binding( + get: { state.currentScannedItem?.nutriments.proteinPer100g ?? 0 }, + set: { state.updateProductNutriment(keyPath: \.proteinPer100g, value: $0) } + ), + unit: "g", + field: .protein, + focusedField: $focusedField + ) + } + } + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Amount input section + amountInputSection + } + } + .padding() + } + .scrollDismissesKeyboard(.interactively) + .scrollIndicators(.hidden) + + // Action buttons at bottom + VStack(spacing: 12) { + // Add & Continue button + Button { + dismissKeyboard() + if state.currentScannedItem != nil { + state.addProductToList() + } + + if isEditingFromList { + isEditingFromList = false + onDismissList() + } + } label: { + Label( + state.isEditingFromList + ? String(localized: "Update") : String(localized: "Add to List"), + systemImage: "plus.circle.fill" + ) + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(.insulin) + + Button { + dismissKeyboard() + state.cancelEditing() + + if isEditingFromList { + isEditingFromList = false + onDismissList() + } + } label: { + Text("Cancel") + .font(.subheadline.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.bordered) + } + .padding(.horizontal) + .padding(.bottom, 16) + .padding(.top, 8) + } + .background(appState.trioBackgroundColor(for: colorScheme).ignoresSafeArea()) + .onChange(of: focusedField) { _, newValue in + // Pause scanner and hide scanner view when numpad is opened + if newValue != nil { + state.isScanning = false + state.isKeyboardVisible = true + } else { + state.isKeyboardVisible = false + } + } + } + + // MARK: - Helper Views + + private var amountInputSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Amount you're eating") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, 8) + + AmountTextField( + amount: $state.editingAmount, + isMl: $state.editingIsMl, + field: .amount, + focusedField: $focusedField + ) + + if state.editingIsMl { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + let options: [(String, Double, String)] = [ + ("0.25l", 250, "l"), + ("0.33l", 333, "l"), + ("0.5l", 500, "l"), + ("1l", 1000, "l") + ] + ForEach(options, id: \.0) { label, value, unit in + let isSelected = state.editingAmount == value + Button { + state.selectQuickPortion(amount: value, unit: unit) + } label: { + Text(label) + .font(.subheadline.weight(isSelected ? .bold : .medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + isSelected ? Color.blue : Color.secondary.opacity(0.15) + ) + .foregroundColor(isSelected ? .white : .primary) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + + // Show calculated nutrition based on amount + if state.editingAmount > 0 { + if let product = state.currentScannedItem { + let carbsTotal = + (product.nutriments.carbohydratesPer100g ?? 0) * state.editingAmount / 100 + let kcalTotal = (product.nutriments.energyKcalPer100g ?? 0) * state.editingAmount / 100 + nutritionSummary(carbs: carbsTotal, kcal: kcalTotal) + } + } + } + } + + private func nutritionSummary(carbs: Double, kcal _: Double) -> some View { + HStack(spacing: 16) { + Text("total \(carbs, specifier: "%.1f") g of carbs") + .foregroundStyle(.blue) + } + .font(.caption) + .padding(.top, 4) + } + + private var productPlaceholder: some View { + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondary.opacity(0.2)) + .overlay( + Image(systemName: "photo") + .foregroundStyle(.secondary) + ) + } + + // MARK: - Helper Functions + + private func dismissKeyboard() { + focusedField = nil + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil + ) + } + } +} diff --git a/Trio/Sources/Modules/BarcodeScanner/View/OpenFoodFactsClient.swift b/Trio/Sources/Modules/BarcodeScanner/View/OpenFoodFactsClient.swift new file mode 100644 index 00000000000..4af3c64f02f --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/View/OpenFoodFactsClient.swift @@ -0,0 +1,365 @@ +import Foundation + +// MARK: - OpenFoodFacts API Client + +extension BarcodeScanner { + /// Client for fetching product data from OpenFoodFacts API + struct OpenFoodFactsClient { + func fetchProduct(barcode: String) async throws -> FoodItem { + guard let url = URL(string: "https://world.openfoodfacts.org/api/v2/product/\(barcode).json") else { + throw OpenFoodFactsError.invalidResponse + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + 200 ..< 300 ~= httpResponse.statusCode + else { + throw OpenFoodFactsError.invalidResponse + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let apiResponse = try decoder.decode(APIResponse.self, from: data) + + guard apiResponse.status == 1, let productData = apiResponse.product else { + throw OpenFoodFactsError.productNotFound + } + + // Decide preferred portion unit for user input + let servingUnit = productData.servingQuantityUnit?.lowercased() ?? productData.productQuantityUnit?.lowercased() + let isMlQuantityUnit: Bool = { + if let unit = servingUnit { + if unit.contains("ml") || unit == "l" || unit.contains("fl oz") { + return true + } + return false + } + return productData.nutriments?.basis == .per100ml + }() + + var imageSource: FoodItem.ImageSource = .none + if let url = productData.imageURL { + imageSource = .url(url) + } + + return FoodItem( + barcode: apiResponse.code, + name: productData.productName?.trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? String(localized: "Unknown product"), + brand: productData.primaryBrand, + quantity: productData.quantity, + servingSize: productData.servingSize, + ingredients: productData.ingredientsText, + imageSource: imageSource, + defaultPortionIsMl: isMlQuantityUnit, + servingQuantity: productData.servingQuantity, + servingQuantityUnit: productData.servingQuantityUnit, + nutriments: .init( + basis: productData.nutriments?.basis ?? .per100g, + energyKcalPer100g: productData.nutriments?.energyKcal100g, + carbohydratesPer100g: productData.nutriments?.carbohydrates100g, + sugarsPer100g: productData.nutriments?.sugars100g, + fatPer100g: productData.nutriments?.fat100g, + proteinPer100g: productData.nutriments?.proteins100g, + fiberPer100g: productData.nutriments?.fiber100g + ) + ) + } + + /// Search products by name/text query + /// - Parameters: + /// - query: The search term to look for + /// - page: Page number for pagination (1-indexed) + /// - pageSize: Number of results per page + /// - Returns: Array of matching FoodItems + func searchProducts(query: String, page: Int = 1, pageSize: Int = 24) async throws -> [FoodItem] { + guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return [] + } + + guard var components = URLComponents(string: "https://world.openfoodfacts.org/cgi/search.pl") else { + throw OpenFoodFactsError.invalidResponse + } + + components.queryItems = [ + URLQueryItem(name: "search_terms", value: query), + URLQueryItem(name: "search_simple", value: "1"), + URLQueryItem(name: "action", value: "process"), + URLQueryItem(name: "json", value: "1"), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)) + ] + + guard let url = components.url else { + throw OpenFoodFactsError.invalidResponse + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Trio-iOS/1.0", forHTTPHeaderField: "User-Agent") + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData // No cache + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + 200 ..< 300 ~= httpResponse.statusCode + else { + throw OpenFoodFactsError.invalidResponse + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let searchResponse = try decoder.decode(SearchAPIResponse.self, from: data) + + return searchResponse.products.compactMap { productData -> FoodItem? in + let servingUnit = productData.servingQuantityUnit?.lowercased() ?? productData.productQuantityUnit? + .lowercased() + let isMlQuantityUnit: Bool = { + if let unit = servingUnit { + if unit.contains("ml") || unit == "l" || unit.contains("fl oz") { + return true + } + return false + } + return productData.nutriments?.basis == .per100ml + }() + + var imageSource: FoodItem.ImageSource = .none + if let url = productData.imageURL { + imageSource = .url(url) + } + + return FoodItem( + barcode: productData.code, + name: productData.productName?.trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? String(localized: "Unknown product"), + brand: productData.primaryBrand, + quantity: productData.quantity, + servingSize: productData.servingSize, + ingredients: productData.ingredientsText, + imageSource: imageSource, + defaultPortionIsMl: isMlQuantityUnit, + servingQuantity: productData.servingQuantity, + servingQuantityUnit: productData.servingQuantityUnit, + nutriments: .init( + basis: productData.nutriments?.basis ?? .per100g, + energyKcalPer100g: productData.nutriments?.energyKcal100g, + carbohydratesPer100g: productData.nutriments?.carbohydrates100g, + sugarsPer100g: productData.nutriments?.sugars100g, + fatPer100g: productData.nutriments?.fat100g, + proteinPer100g: productData.nutriments?.proteins100g, + fiberPer100g: productData.nutriments?.fiber100g + ) + ) + } + } + } +} + +// MARK: - Errors + +extension BarcodeScanner { + enum OpenFoodFactsError: LocalizedError { + case invalidResponse + case productNotFound + + var errorDescription: String? { + switch self { + case .invalidResponse: + String(localized: "Unable to reach OpenFoodFacts. Please try again.") + case .productNotFound: + String(localized: "No product information was found for this barcode.") + } + } + } +} + +// MARK: - Private API Response Types + +private extension BarcodeScanner.OpenFoodFactsClient { + struct APIResponse: Decodable { + let status: Int + let statusVerbose: String + let code: String + let product: ProductData? + } + + /// Response structure for search API endpoint + struct SearchAPIResponse: Decodable { + let count: Int + let page: Int + let pageSize: Int + let products: [ProductData] + } + + struct ProductData: Decodable { + let code: String? + let productName: String? + let brands: String? + let quantity: String? + let productQuantityUnit: String? + let servingSize: String? + let servingQuantity: Double? + let servingQuantityUnit: String? + let ingredientsText: String? + let imageUrl: String? + let imageFrontUrl: String? + let imageFrontThumbUrl: String? + let nutriments: NutrimentsData? + + private enum CodingKeys: String, CodingKey { + case code + case productName + case brands + case quantity + case productQuantityUnit + case servingSize + case servingQuantity + case servingQuantityUnit + case ingredientsText + case imageUrl + case imageFrontUrl + case imageFrontThumbUrl + case nutriments + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(String.self, forKey: .code) + productName = try container.decodeIfPresent(String.self, forKey: .productName) + brands = try container.decodeIfPresent(String.self, forKey: .brands) + quantity = try container.decodeIfPresent(String.self, forKey: .quantity) + productQuantityUnit = try container.decodeIfPresent(String.self, forKey: .productQuantityUnit) + servingSize = try container.decodeIfPresent(String.self, forKey: .servingSize) + servingQuantityUnit = try container.decodeIfPresent(String.self, forKey: .servingQuantityUnit) + ingredientsText = try container.decodeIfPresent(String.self, forKey: .ingredientsText) + imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) + imageFrontUrl = try container.decodeIfPresent(String.self, forKey: .imageFrontUrl) + imageFrontThumbUrl = try container.decodeIfPresent(String.self, forKey: .imageFrontThumbUrl) + nutriments = try container.decodeIfPresent(NutrimentsData.self, forKey: .nutriments) + + // servingQuantity can be either a Double or a String in the API response + if let doubleValue = try? container.decodeIfPresent(Double.self, forKey: .servingQuantity) { + servingQuantity = doubleValue + } else if let stringValue = try? container.decodeIfPresent(String.self, forKey: .servingQuantity), + let parsed = Double(stringValue.replacingOccurrences(of: ",", with: ".")) + { + servingQuantity = parsed + } else { + servingQuantity = nil + } + } + + var primaryBrand: String? { + brands? + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first + } + + var imageURL: URL? { + [imageFrontUrl, imageFrontThumbUrl, imageUrl] + .compactMap { $0 } + .compactMap { URL(string: $0) } + .first + } + } + + struct NutrimentsData: Decodable { + let basis: BarcodeScanner.FoodItem.Nutriments.Basis + let energyKcal100g: Double? + let carbohydrates100g: Double? + let sugars100g: Double? + let fat100g: Double? + let proteins100g: Double? + let fiber100g: Double? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode([String: NumericValue].self) + + func value(_ key: String, fallbacks: [String] = []) -> Double? { + if let v = raw[key]?.doubleValue { + return v + } + for fb in fallbacks { + if let v = raw[fb]?.doubleValue { + return v + } + } + return nil + } + + energyKcal100g = value( + "energy-kcal_100g", + fallbacks: ["energy-kcal_100ml", "energy-kcal_serving"] + ) + carbohydrates100g = value( + "carbohydrates_100g", + fallbacks: ["carbohydrates_100ml", "carbohydrates_serving"] + ) + sugars100g = value( + "sugars_100g", + fallbacks: ["sugars_100ml", "sugars_serving"] + ) + fat100g = value( + "fat_100g", + fallbacks: ["fat_100ml", "fat_serving"] + ) + proteins100g = value( + "proteins_100g", + fallbacks: ["proteins_100ml", "proteins_serving"] + ) + fiber100g = value( + "fiber_100g", + fallbacks: ["fiber_100ml", "fiber_serving"] + ) + + // Decide if data is per 100g or per 100ml based on available keys + let hasPer100g = raw.keys.contains { $0.hasSuffix("_100g") } + let hasPer100ml = raw.keys.contains { $0.hasSuffix("_100ml") } + + if hasPer100ml, !hasPer100g { + basis = .per100ml + } else { + basis = .per100g + } + } + + /// Helper type that can decode either a number or a string and expose it as Double + private struct NumericValue: Decodable { + let doubleValue: Double? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let d = try? container.decode(Double.self) { + doubleValue = d + return + } + + if let s = try? container.decode(String.self) { + doubleValue = Double(s.replacingOccurrences(of: ",", with: ".")) + return + } + + doubleValue = nil + } + } + } +} + +// MARK: - String Extension + +private extension Optional where Wrapped == String { + var nonEmpty: String? { + guard let trimmed = self?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/Trio/Sources/Modules/BarcodeScanner/View/ProductSearchField.swift b/Trio/Sources/Modules/BarcodeScanner/View/ProductSearchField.swift new file mode 100644 index 00000000000..82b182c9244 --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/View/ProductSearchField.swift @@ -0,0 +1,57 @@ +import SwiftUI + +extension BarcodeScanner { + /// A reusable search text field component + struct ProductSearchField: View { + @Binding var searchText: String + var isFocused: FocusState.Binding + var onSubmit: () -> Void + var onClear: () -> Void + var onChange: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField(String(localized: "Search foods..."), text: $searchText) + .focused(isFocused) + .textFieldStyle(.plain) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .submitLabel(.search) + .onSubmit(onSubmit) + .toolbar { + if isFocused.wrappedValue { + ToolbarItemGroup(placement: .keyboard) { + Button(action: { + searchText = "" + }) { + Image(systemName: "trash") + } + Spacer() + Button(action: { + isFocused.wrappedValue = false + }) { + Image(systemName: "keyboard.chevron.compact.down") + } + } + } + } + .onChange(of: searchText) { _, _ in + onChange() + } + + if !searchText.isEmpty { + Button(action: onClear) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(10) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } +} diff --git a/Trio/Sources/Modules/BarcodeScanner/View/ProductViews.swift b/Trio/Sources/Modules/BarcodeScanner/View/ProductViews.swift new file mode 100644 index 00000000000..b828d3609a3 --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/View/ProductViews.swift @@ -0,0 +1,361 @@ +import SwiftUI + +// MARK: - Product Details View + +extension BarcodeScanner { + struct ProductDetailsView: View { + let product: FoodItem + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 16) { + switch product.imageSource { + case let .url(url): + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFill() + case .failure: + placeholder + default: + ProgressView() + } + } + .frame(width: 88, height: 88) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + case let .image(uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(width: 88, height: 88) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + case .none: + placeholder + .frame(width: 88, height: 88) + } + + VStack(alignment: .leading, spacing: 6) { + Text(product.name) + .font(.headline) + if let brand = product.brand { + Text(brand) + .font(.subheadline) + .foregroundStyle(.secondary) + } + if let quantity = product.quantity { + Text(quantity) + .font(.footnote) + .foregroundStyle(.secondary) + } + if let serving = product.servingSize { + Text("Serving: \(serving)") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + Spacer() + } + + NutrimentGrid(nutriments: product.nutriments) + + if let ingredients = product.ingredients { + VStack(alignment: .leading, spacing: 4) { + Text("Ingredients") + .font(.subheadline.weight(.semibold)) + Text(ingredients) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + .padding() + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private var placeholder: some View { + RoundedRectangle(cornerRadius: 16) + .fill(Color.secondary.opacity(0.2)) + .overlay( + Image(systemName: "photo") + .foregroundStyle(.secondary) + ) + } + } + + struct ScannedProductRow: View { + let item: FoodItem + var state: StateModel + var focusedItemID: FocusState.Binding + + @State private var amount: Double = 0 + @State private var isMlInput: Bool = false + @State private var showQuickSelector: Bool = false + + private var formatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + return formatter + } + + var body: some View { + let isFocused = focusedItemID.wrappedValue == item.id + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 12) { + productImage + + VStack(alignment: .leading, spacing: 6) { + Text(item.name) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + + if let brand = item.brand { + Text(brand) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + KeyboardToolbarTextField( + value: $amount, + formatter: formatter, + configuration: .init( + keyboardType: .decimalPad, + textAlignment: .left, + placeholder: "0", + font: .systemFont(ofSize: 17, weight: .bold) + ), + onFocusContext: { isEntering in + if isEntering { + focusedItemID.wrappedValue = item.id + } else if isFocused { + focusedItemID.wrappedValue = nil + } + }, + externalFocus: isFocused + ) + .frame(width: 70) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onChange(of: amount) { _, newValue in + state.updateScannedProductAmount(item, amount: newValue, isMlInput: isMlInput) + } + + Picker("", selection: $isMlInput) { + Text("g").tag(false) + Text("ml").tag(true) + } + .pickerStyle(.segmented) + .frame(width: 85) + .onChange(of: isMlInput) { _, newValue in + state.updateScannedProductAmount(item, amount: amount, isMlInput: newValue) + } + } + } + + Spacer() + } + .padding() + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { + showQuickSelector.toggle() + } + } + + if showQuickSelector { + multiplierWheel + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .onAppear { + updateFromItem() + } + .onChange(of: item.amount) { _, _ in + updateFromItem() + } + .onChange(of: item.isMlInput) { _, _ in + updateFromItem() + } + } + + private func updateFromItem() { + amount = item.amount + isMlInput = item.isMlInput + } + + private func updateAmount(_ amount: Double) { + guard amount.isFinite else { return } + self.amount = amount + state.updateScannedProductAmount(item, amount: amount, isMlInput: isMlInput) + } + + private func stepMultiplier(by value: Int) { + let base = item.servingQuantity ?? 100 + let current = base > 0 ? Int(round(item.amount / base)) : 1 + let next = max(1, current + value) + quickSelectMultiplier(next) + } + + @ViewBuilder private var multiplierWheel: some View { + let base = item.servingQuantity ?? 100 + let current = base > 0 ? Int(round(item.amount / base)) : 1 + + HStack(spacing: 12) { + Button { + stepMultiplier(by: -1) + } label: { + Image(systemName: "minus") + .font(.title2.weight(.bold)) + .frame(maxWidth: .infinity) + .frame(height: 60) + .background(Color.accentColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + + VStack(spacing: 2) { + Text("\(current)x") + .font(.title2.weight(.bold)) + Text(current == 1 ? String(localized: "Portion") : String(localized: "Portions")) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + .frame(width: 100, height: 60) + .background(Color.accentColor.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + Button { + stepMultiplier(by: 1) + } label: { + Image(systemName: "plus") + .font(.title2.weight(.bold)) + .frame(maxWidth: .infinity) + .frame(height: 60) + .background(Color.accentColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + } + .padding(.horizontal) + .padding(.bottom, 16) + } + + private func quickSelectMultiplier(_ multiplier: Int) { + if let servingQuantity = item.servingQuantity, servingQuantity > 0 { + updateAmount(servingQuantity * Double(multiplier)) + } else { + updateAmount(Double(multiplier) * 100) + } + } + + @ViewBuilder private var productImage: some View { + switch item.imageSource { + case let .image(uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + case let .url(url): + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFill() + case .failure: + placeholder + default: + ProgressView() + } + } + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + case .none: + placeholder + .frame(width: 60, height: 60) + } + } + + private var placeholder: some View { + RoundedRectangle(cornerRadius: 12) + .fill(Color.secondary.opacity(0.2)) + .overlay( + Image(systemName: "photo") + .foregroundStyle(.secondary) + .font(.caption) + ) + } + } + + struct NutrimentGrid: View { + let nutriments: FoodItem.Nutriments + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Per 100\(nutriments.basis == .per100ml ? "ml" : "g")") + .font(.subheadline.weight(.semibold)) + + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 2), spacing: 12 + ) { + NutrimentTile( + title: String(localized: "Energy (kcal)"), value: nutriments.energyKcalPer100g, + unit: "kcal" + ) + NutrimentTile( + title: String(localized: "Carbs"), value: nutriments.carbohydratesPer100g, unit: "g" + ) + NutrimentTile( + title: String(localized: "Sugars"), value: nutriments.sugarsPer100g, unit: "g" + ) + NutrimentTile(title: String(localized: "Fat"), value: nutriments.fatPer100g, unit: "g") + NutrimentTile( + title: String(localized: "Protein"), value: nutriments.proteinPer100g, unit: "g" + ) + NutrimentTile( + title: String(localized: "Fiber"), value: nutriments.fiberPer100g, unit: "g" + ) + } + } + } + } + + struct NutrimentTile: View { + let title: String + let value: Double? + let unit: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(formattedValue) + .font(.headline) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var formattedValue: String { + guard let value else { return "—" } + return "\(String(format: "%.1f", value)) \(unit)" + } + } +} diff --git a/Trio/Sources/Modules/BarcodeScanner/View/ScannerPreviewView.swift b/Trio/Sources/Modules/BarcodeScanner/View/ScannerPreviewView.swift new file mode 100644 index 00000000000..60eadc4bb47 --- /dev/null +++ b/Trio/Sources/Modules/BarcodeScanner/View/ScannerPreviewView.swift @@ -0,0 +1,430 @@ +import AVFoundation +import CoreImage +import CoreMedia +import SwiftUI +import UIKit + +// MARK: - Barcode Scanner Preview + +extension BarcodeScanner { + struct ScannerPreviewView: UIViewRepresentable { + @Binding var isRunning: Bool + var supportedTypes: [AVMetadataObject.ObjectType] = [.ean13, .ean8, .upce, .code128, .code39] + let onDetected: (String) -> Void + let onFailure: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + isRunning: $isRunning, + supportedTypes: supportedTypes, + onDetected: onDetected, + onFailure: onFailure + ) + } + + func makeUIView(context: Context) -> CameraPreviewView { + let view = CameraPreviewView() + view.coordinator = context.coordinator + context.coordinator.attach(to: view) + return view + } + + func updateUIView(_: CameraPreviewView, context: Context) { + context.coordinator.setRunning(isRunning) + } + + static func dismantleUIView(_: CameraPreviewView, coordinator: Coordinator) { + coordinator.cleanup() + } + } +} + +// MARK: - Coordinator + +extension BarcodeScanner.ScannerPreviewView { + final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { + private var isRunning: Binding + private let supportedTypes: [AVMetadataObject.ObjectType] + private let onDetected: (String) -> Void + private let onFailure: (String) -> Void + + private let session = AVCaptureSession() + private let metadataOutput = AVCaptureMetadataOutput() + private var isConfigured = false + private let sessionQueue = DispatchQueue(label: "camera.session.queue") + + private var currentDevice: AVCaptureDevice? + private weak var previewView: CameraPreviewView? + private var focusObservation: NSKeyValueObservation? + private var lastFocusTime: Date = .distantPast + + init( + isRunning: Binding, + supportedTypes: [AVMetadataObject.ObjectType], + onDetected: @escaping (String) -> Void, + onFailure: @escaping (String) -> Void, + ) { + self.isRunning = isRunning + self.supportedTypes = supportedTypes + self.onDetected = onDetected + self.onFailure = onFailure + super.init() + } + + func cleanup() { + focusObservation?.invalidate() + focusObservation = nil + Foundation.NotificationCenter.default.removeObserver(self) + sessionQueue.async { [weak self] in + guard let self, self.session.isRunning else { return } + self.session.stopRunning() + } + } + + func attach(to view: CameraPreviewView) { + previewView = view + view.videoPreviewLayer.session = session + view.videoPreviewLayer.videoGravity = .resizeAspectFill + + sessionQueue.async { [weak self] in + self?.configureIfNeeded() + + DispatchQueue.main.async { + guard let self else { return } + self.setRunning(self.isRunning.wrappedValue) + } + } + } + + func setRunning(_ shouldRun: Bool) { + guard isConfigured else { return } + sessionQueue.async { [weak self] in + guard let self else { return } + if shouldRun, !self.session.isRunning { + self.session.startRunning() + } else if !shouldRun, self.session.isRunning { + self.session.stopRunning() + } + } + } + + // ... tap to focus ... + + private func startSession() { + // Deprecated helper, logic moved to setRunning + setRunning(true) + } + + // ... + + private func configureIfNeeded() { + // Must be called on sessionQueue + guard !isConfigured else { return } + + session.beginConfiguration() + session.sessionPreset = .high + + let device = getBestCameraForScanning() + + guard let device else { + DispatchQueue.main.async { self.onFailure(String(localized: "Camera is not available on this device.")) } + session.commitConfiguration() + return + } + + currentDevice = device + configureFocusSettings(for: device) + + do { + let input = try AVCaptureDeviceInput(device: device) + guard session.canAddInput(input) else { + DispatchQueue.main.async { self.onFailure(String(localized: "Unable to use the back camera.")) } + session.commitConfiguration() + return + } + session.addInput(input) + } catch { + DispatchQueue.main + .async { self.onFailure(String(localized: "Failed to configure camera: \(error.localizedDescription)")) } + session.commitConfiguration() + return + } + + guard session.canAddOutput(metadataOutput) else { + DispatchQueue.main.async { self.onFailure(String(localized: "Unable to read barcodes on this device.")) } + session.commitConfiguration() + return + } + + session.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + metadataOutput.metadataObjectTypes = supportedTypes + + session.commitConfiguration() + isConfigured = true + + setupFocusMonitoring() + } + + func metadataOutput( + _: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from _: AVCaptureConnection + ) { + guard isRunning.wrappedValue else { return } + guard let readableObject = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first, + let stringValue = readableObject.stringValue + else { + return + } + + onDetected(stringValue) + } + + // MARK: - Missing Helper Methods + + func handleTapToFocus(at point: CGPoint, in view: UIView) { + guard let device = currentDevice, + let previewLayer = (view as? CameraPreviewView)?.videoPreviewLayer + else { return } + + let focusPoint = previewLayer.captureDevicePointConverted(fromLayerPoint: point) + + do { + try device.lockForConfiguration() + + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = focusPoint + } + + if device.isExposurePointOfInterestSupported { + device.exposurePointOfInterest = focusPoint + } + + if device.isFocusModeSupported(.autoFocus) { + device.focusMode = .autoFocus + } + + if device.isExposureModeSupported(.autoExpose) { + device.exposureMode = .autoExpose + } + + device.unlockForConfiguration() + lastFocusTime = Date() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.switchToContinuousAutoFocus() + } + } catch {} + } + + private func switchToContinuousAutoFocus() { + guard let device = currentDevice else { return } + + do { + try device.lockForConfiguration() + + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + if device.isExposureModeSupported(.continuousAutoExposure) { + device.exposureMode = .continuousAutoExposure + } + + device.unlockForConfiguration() + } catch {} + } + + private func setupFocusMonitoring() { + guard let device = currentDevice else { return } + + Foundation.NotificationCenter.default.addObserver( + self, + selector: #selector(subjectAreaDidChange), + name: NSNotification.Name.AVCaptureDeviceSubjectAreaDidChange, + object: device + ) + + focusObservation = device.observe(\.lensPosition, options: [.new]) { [weak self] device, _ in + guard let self else { return } + + let now = Date() + if now.timeIntervalSince(self.lastFocusTime) > 3.0 { + if !device.isAdjustingFocus, device.lensPosition < 0.1 || device.lensPosition > 0.9 { + self.triggerRefocus() + } + } + } + } + + @objc private func subjectAreaDidChange(_: Notification) { + guard let device = currentDevice else { return } + + let now = Date() + guard now.timeIntervalSince(lastFocusTime) > 1.0 else { return } + + do { + try device.lockForConfiguration() + + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5) + } + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + + if device.isExposurePointOfInterestSupported { + device.exposurePointOfInterest = CGPoint(x: 0.5, y: 0.5) + } + if device.isExposureModeSupported(.continuousAutoExposure) { + device.exposureMode = .continuousAutoExposure + } + + device.unlockForConfiguration() + lastFocusTime = now + } catch {} + } + + private func triggerRefocus() { + guard let device = currentDevice else { return } + + do { + try device.lockForConfiguration() + + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5) + } + + if device.isFocusModeSupported(.autoFocus) { + device.focusMode = .autoFocus + } + + device.unlockForConfiguration() + lastFocusTime = Date() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.switchToContinuousAutoFocus() + } + } catch {} + } + + private func getBestCameraForScanning() -> AVCaptureDevice? { + if #available(iOS 15.4, *) { + if let multiCamera = AVCaptureDevice.default(.builtInDualWideCamera, for: .video, position: .back) { + return multiCamera + } + if let tripleCamera = AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back) { + return tripleCamera + } + } + return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + } + + private func configureFocusSettings(for device: AVCaptureDevice) { + do { + try device.lockForConfiguration() + + if device.isFocusModeSupported(.continuousAutoFocus) { + device.focusMode = .continuousAutoFocus + } + + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5) + } + + if device.isAutoFocusRangeRestrictionSupported { + device.autoFocusRangeRestriction = .near + } + + if device.isSubjectAreaChangeMonitoringEnabled == false { + device.isSubjectAreaChangeMonitoringEnabled = true + } + + if device.isExposureModeSupported(.continuousAutoExposure) { + device.exposureMode = .continuousAutoExposure + } + if device.isExposurePointOfInterestSupported { + device.exposurePointOfInterest = CGPoint(x: 0.5, y: 0.5) + } + + if device.isLowLightBoostSupported { + device.automaticallyEnablesLowLightBoostWhenAvailable = true + } + + if #available(iOS 15.4, *) { + if device.automaticallyAdjustsFaceDrivenAutoFocusEnabled { + device.automaticallyAdjustsFaceDrivenAutoFocusEnabled = false + } + } + + device.unlockForConfiguration() + } catch {} + } + } +} + +// MARK: - Camera Preview View + +final class CameraPreviewView: UIView { + weak var coordinator: BarcodeScanner.ScannerPreviewView.Coordinator? + private var focusIndicator: UIView? + + override class var layerClass: AnyClass { + AVCaptureVideoPreviewLayer.self + } + + var videoPreviewLayer: AVCaptureVideoPreviewLayer { + layer as! AVCaptureVideoPreviewLayer + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupTapGesture() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupTapGesture() + } + + private func setupTapGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + addGestureRecognizer(tapGesture) + isUserInteractionEnabled = true + } + + @objc private func handleTap(_ gesture: UITapGestureRecognizer) { + let point = gesture.location(in: self) + coordinator?.handleTapToFocus(at: point, in: self) + showFocusIndicator(at: point) + } + + private func showFocusIndicator(at point: CGPoint) { + focusIndicator?.removeFromSuperview() + + let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80)) + indicator.center = point + indicator.layer.borderColor = UIColor.systemYellow.cgColor + indicator.layer.borderWidth = 2 + indicator.layer.cornerRadius = 10 + indicator.backgroundColor = .clear + indicator.alpha = 0 + addSubview(indicator) + focusIndicator = indicator + + UIView.animateKeyframes(withDuration: 1.5, delay: 0, options: []) { + UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.1) { + indicator.alpha = 1 + indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) + } + UIView.addKeyframe(withRelativeStartTime: 0.1, relativeDuration: 0.2) { + indicator.transform = .identity + } + UIView.addKeyframe(withRelativeStartTime: 0.8, relativeDuration: 0.2) { + indicator.alpha = 0 + } + } completion: { _ in + indicator.removeFromSuperview() + } + } +} diff --git a/Trio/Sources/Modules/BolusCalculatorConfig/BolusCalculatorStateModel.swift b/Trio/Sources/Modules/BolusCalculatorConfig/BolusCalculatorStateModel.swift index afa135ba085..1e46f2d5d12 100644 --- a/Trio/Sources/Modules/BolusCalculatorConfig/BolusCalculatorStateModel.swift +++ b/Trio/Sources/Modules/BolusCalculatorConfig/BolusCalculatorStateModel.swift @@ -10,6 +10,8 @@ extension BolusCalculatorConfig { @Published var sweetMealFactor: Decimal = 0 @Published var displayPresets: Bool = true @Published var confirmBolusWhenVeryLowGlucose: Bool = false + @Published var barcodeScannerEnabled: Bool = false + @Published var barcodeScannerOnlyCarbs: Bool = false override func subscribe() { units = settingsManager.settings.units @@ -21,6 +23,10 @@ extension BolusCalculatorConfig { subscribeSetting(\.sweetMeals, on: $sweetMeals) { sweetMeals = $0 } subscribeSetting(\.sweetMealFactor, on: $sweetMealFactor) { sweetMealFactor = $0 } subscribeSetting(\.confirmBolus, on: $confirmBolusWhenVeryLowGlucose) { confirmBolusWhenVeryLowGlucose = $0 } + subscribeSetting(\.barcodeScannerEnabled, on: $barcodeScannerEnabled) { + barcodeScannerEnabled = $0 } + subscribeSetting(\.barcodeScannerOnlyCarbs, on: $barcodeScannerOnlyCarbs) { + barcodeScannerOnlyCarbs = $0 } } } } diff --git a/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift b/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift index 09019533c5f..6c77419d758 100644 --- a/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift +++ b/Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift @@ -185,6 +185,65 @@ extension BolusCalculatorConfig { ) } ) + + SettingInputSection( + decimalValue: $decimalPlaceholder, + booleanValue: $state.barcodeScannerEnabled, + shouldDisplayHint: $shouldDisplayHint, + selectedVerboseHint: Binding( + get: { selectedVerboseHint }, + set: { + selectedVerboseHint = $0.map { AnyView($0) } + hintLabel = String(localized: "Enable Barcode Scanner") + } + ), + units: state.units, + type: .boolean, + label: String(localized: "Enable Barcode Scanner"), + miniHint: String( + localized: "Scan Qr-Codes to enter carbs" + ), + verboseHint: VStack(alignment: .leading, spacing: 10) { + Text("Default: OFF").bold() + Text( + "Enables you to scann barcodes and get nutritional data direcltly in the app." + ) + Text( + "To add more then one food item scan again and optionally edit or remove specific info of the item in the listview." + ) + Text( + "Note: Nutritional data can be slightly inaccurate as producers might change the recipes over time of specific products. It's recomended to check the nutrional data on first scan. Also if wrong data is seen maybe supporting the openfoodfacs project via correcting the nutritional data in thier app is a option." + ) + } + ) + + SettingInputSection( + decimalValue: $decimalPlaceholder, + booleanValue: $state.barcodeScannerOnlyCarbs, + shouldDisplayHint: $shouldDisplayHint, + selectedVerboseHint: Binding( + get: { selectedVerboseHint }, + set: { + selectedVerboseHint = $0.map { AnyView($0) } + hintLabel = String(localized: "Only allow Carbs from Barcode Scanner") + } + ), + units: state.units, + type: .boolean, + label: String(localized: "Only allow Carbs from Barcode Scanner"), + miniHint: String( + localized: "Do not allow logging of FTUs" + ), + verboseHint: VStack(alignment: .leading, spacing: 10) { + Text("Default: OFF").bold() + Text( + "For users who only want to log carbs via the barcode scanner and not FTUs (Fats, Proteins, and Fibers)." + ) + Text( + "Note: Enable this if FTUs are causing issues for you." + ) + } + ) } .listSectionSpacing(sectionSpacing) .sheet(isPresented: $shouldDisplayHint) { diff --git a/Trio/Sources/Modules/Home/HomeStateModel.swift b/Trio/Sources/Modules/Home/HomeStateModel.swift index 90ec89622e2..d0d2d78b9d1 100644 --- a/Trio/Sources/Modules/Home/HomeStateModel.swift +++ b/Trio/Sources/Modules/Home/HomeStateModel.swift @@ -48,6 +48,7 @@ extension Home { var reservoir: Decimal? var pumpName = "" var pumpExpiresAtDate: Date? + var pumpActivatedAtDate: Date? var highTTraisesSens: Bool = false var lowTTlowersSens: Bool = false var isExerciseModeActive: Bool = false @@ -345,6 +346,11 @@ extension Home { .weakAssign(to: \.pumpExpiresAtDate, on: self) .store(in: &lifetime) + apsManager.pumpActivatedAtDate + .receive(on: DispatchQueue.main) + .weakAssign(to: \.pumpActivatedAtDate, on: self) + .store(in: &lifetime) + apsManager.lastError .receive(on: DispatchQueue.main) .map { [weak self] error in diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift b/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift index 593c5ccb9a4..8f64666a50c 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartElements/BasalChart.swift @@ -177,6 +177,7 @@ extension MainChartView { let startOfDay = Calendar.current.startOfDay(for: beginDate) let profile = state.basalProfile var basalPoints: [BasalProfile] = [] + var lastEntryBeforeRange: (amount: Double, date: Date)? // Iterate over the next three days, multiplying the time intervals for dayOffset in 0 ..< 3 { @@ -185,8 +186,12 @@ extension MainChartView { let basalTime = startOfDay.addingTimeInterval(entry.minutes.minutes.timeInterval + dayTimeOffset) let basalTimeInterval = basalTime.timeIntervalSince1970 - // Only append points within the timeBegin and timeEnd range - if basalTimeInterval >= timeBegin, basalTimeInterval < timeEnd { + if basalTimeInterval < timeBegin { + // Track the last profile entry before the visible range + if lastEntryBeforeRange == nil || basalTime > lastEntryBeforeRange!.date { + lastEntryBeforeRange = (amount: Double(entry.rate), date: basalTime) + } + } else if basalTimeInterval < timeEnd { basalPoints.append(BasalProfile( amount: Double(entry.rate), isOverwritten: false, @@ -196,6 +201,15 @@ extension MainChartView { } } + // Include the active profile entry at timeBegin so the line starts at the chart's left edge + if let lastBefore = lastEntryBeforeRange { + basalPoints.append(BasalProfile( + amount: lastBefore.amount, + isOverwritten: false, + startDate: beginDate + )) + } + return basalPoints } diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartElements/CobIobChart.swift b/Trio/Sources/Modules/Home/View/Chart/ChartElements/CobIobChart.swift index ab152a39a5c..82388e74dba 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartElements/CobIobChart.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartElements/CobIobChart.swift @@ -37,11 +37,28 @@ extension MainChartView { axis: "IOB", color: Color.darkerBlue ) + + // ISF selection point + let isfValue = selectedIOBValue.insulinSensitivity?.doubleValue ?? 0 + PointMark( + x: .value("Time", selectedIOBValue.deliverAt ?? Date.now, unit: .minute), + y: .value("ISF", isfValue) + ) + .symbolSize(CGSize(width: 10, height: 10)) + .foregroundStyle(Color.white.opacity(0.8)) + + PointMark( + x: .value("Time", selectedIOBValue.deliverAt ?? Date.now, unit: .minute), + y: .value("ISF", isfValue) + ) + .symbolSize(CGSize(width: 4, height: 4)) + .foregroundStyle(Color.primary) } } .chartForegroundStyleScale([ "COB": Color.orange, - "IOB": Color.darkerBlue + "IOB": Color.darkerBlue, + "ISF": Color.white ]) .chartLegend(.hidden) .frame(minHeight: geo.size.height * 0.12) @@ -56,9 +73,15 @@ extension MainChartView { func combinedYDomain() -> ClosedRange { let iobMin = scaleIobAmountForChart(state.minValueIobChart) let iobMax = scaleIobAmountForChart(state.maxValueIobChart) - let minValue = min(state.minValueCobChart, iobMin) - let maxValue = max(state.maxValueCobChart, iobMax) - return Double(minValue) ... Double(maxValue) + + // Calculate ISF min/max from determinations + let isfValues = state.enactedAndNonEnactedDeterminations.compactMap { $0.insulinSensitivity?.doubleValue } + let isfMin = isfValues.min() ?? 0 + let isfMax = isfValues.max() ?? 0 + + let minValue = min(Double(state.minValueCobChart), Double(iobMin), isfMin) + let maxValue = max(Double(state.maxValueCobChart), Double(iobMax), isfMax) + return minValue ... maxValue } private func drawSelectedInnerPoint(xValue: Date, yValue: Double, axis: String) -> some ChartContent { @@ -137,6 +160,14 @@ extension MainChartView { LineMark(x: .value("Time", date), y: .value("Amount", amountIOB)) .foregroundStyle(by: .value("Type", "IOB")) .position(by: .value("Axis", "IOB")) + + // MARK: - ISF line (no fill) + + let isfValue = item.insulinSensitivity?.doubleValue ?? 0 + + LineMark(x: .value("Time", date), y: .value("ISF", isfValue)) + .foregroundStyle(by: .value("Type", "ISF")) + .lineStyle(StrokeStyle(lineWidth: 1)) } } } diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift b/Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift index 39150458722..3d631356ab4 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift @@ -39,7 +39,7 @@ struct SelectionPopoverView: ChartContent { .annotation( position: .top, alignment: .center, - overflowResolution: .init(x: .fit(to: .chart), y: .disabled) + overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .plot)) ) { selectionPopover } @@ -105,6 +105,16 @@ struct SelectionPopoverView: ChartContent { } .foregroundStyle(Color.orange).font(.body) } + + if let selectedIOBValue, let isf = selectedIOBValue.insulinSensitivity { + HStack { + Image(systemName: "arrow.up.arrow.down").frame(width: 15) + Text(Formatter.integerFormatter.string(from: isf) ?? "") + .bold() + + Text(String(localized: " ISF", comment: "Insulin Sensitivity Factor")) + } + .foregroundStyle(Color.white).font(.body) + } } .padding(.horizontal) .padding(.vertical, 2) diff --git a/Trio/Sources/Modules/Home/View/Chart/ChartLegendView.swift b/Trio/Sources/Modules/Home/View/Chart/ChartLegendView.swift index 450f91486d2..7d308ccb187 100644 --- a/Trio/Sources/Modules/Home/View/Chart/ChartLegendView.swift +++ b/Trio/Sources/Modules/Home/View/Chart/ChartLegendView.swift @@ -164,6 +164,15 @@ struct ChartLegendView: View { color: Color.orange.opacity(0.8), iconString: "line.diagonal" ) + + DefinitionRow( + term: String(localized: "Insulin Sensitivity Factor (ISF)"), + definition: Text( + "Shows how much your blood glucose is expected to drop per unit of insulin. This value is calculated by the algorithm and may vary based on Dynamic ISF settings." + ), + color: Color.white.opacity(0.8), + iconString: "line.diagonal" + ) }.listRowBackground(Color.gray.opacity(0.1)) } .scrollContentBackground(.hidden) diff --git a/Trio/Sources/Modules/Home/View/Header/PumpView.swift b/Trio/Sources/Modules/Home/View/Header/PumpView.swift index eea2234321e..c28e1511a81 100644 --- a/Trio/Sources/Modules/Home/View/Header/PumpView.swift +++ b/Trio/Sources/Modules/Home/View/Header/PumpView.swift @@ -5,11 +5,14 @@ struct PumpView: View { let reservoir: Decimal? let name: String let expiresAtDate: Date? + let activatedAtDate: Date? let timerDate: Date let pumpStatusHighlightMessage: String? let battery: [OpenAPS_Battery] @Environment(\.colorScheme) var colorScheme + let NORMAL_PATCH_AGE = TimeInterval.hours(80) + private var batteryFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .percent @@ -17,6 +20,7 @@ struct PumpView: View { } private var hourglassIcon: String { + if activatedAtDate != nil { return "hourglass.badge.plus" } guard let expiration = expiresAtDate else { return "hourglass" } let hoursRemaining = expiration.timeIntervalSince(timerDate) / 3600 @@ -96,34 +100,38 @@ struct PumpView: View { } if let date = expiresAtDate { - HStack { - Image(systemName: hourglassIcon) - .font(.callout) - .foregroundStyle(timerColor, Color.yellow) - .symbolRenderingMode(.palette) - - let remainingTimeString = remainingTimeString(time: date.timeIntervalSince(timerDate)) - - Text(remainingTimeString) - .font(date.timeIntervalSince(timerDate) > 0 ? .callout : .subheadline) - .fontWeight(.bold) - .fontDesign(.rounded) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame( - // If the string is > 6 chars, i.e., exceeds "xd yh", limit width to 80 pts - // This forces the "Replace pod" string to wrap to 2 lines. - maxWidth: remainingTimeString.count > 6 ? 80 : .infinity, - alignment: .leading - ) - } - // aligns the stopwatch icon exactly with the first pixel of the reservoir icon - .padding(.leading, date.timeIntervalSince(timerDate) > 0 ? 12 : 0) + PatchTimer(forDate: date) } } } } + @ViewBuilder private func PatchTimer(forDate date: Date) -> some View { + HStack { + Image(systemName: hourglassIcon) + .font(.callout) + .foregroundStyle(timerColor, timerColorSecondary) + .symbolRenderingMode(.palette) + + let remainingTimeString = remainingTimeString(time: date.timeIntervalSince(timerDate)) + + Text(remainingTimeString) + .font(date.timeIntervalSince(timerDate) > 0 ? .callout : .subheadline) + .fontWeight(.bold) + .fontDesign(.rounded) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame( + // If the string is > 6 chars, i.e., exceeds "xd yh", limit width to 80 pts + // This forces the "Replace pod" string to wrap to 2 lines. + maxWidth: remainingTimeString.count > 6 ? 80 : .infinity, + alignment: .leading + ) + } + // aligns the stopwatch icon exactly with the first pixel of the reservoir icon + .padding(.leading, date.timeIntervalSince(timerDate) > 0 || activatedAtDate != nil ? 12 : 0) + } + private func remainingTimeString(time: TimeInterval) -> String { guard time > 0 else { return String(localized: "Replace pod", comment: "View/Header when pod expired") @@ -184,11 +192,15 @@ struct PumpView: View { } private var timerColor: Color { - guard let expisesAt = expiresAtDate else { + if let activatedAt = activatedAtDate { + return abs(activatedAt.timeIntervalSinceNow) > NORMAL_PATCH_AGE ? Color.yellow : Color.loopGreen + } + + guard let expiresAt = expiresAtDate else { return .gray } - let time = expisesAt.timeIntervalSince(timerDate) + let time = expiresAt.timeIntervalSince(timerDate) switch time { case ...8.hours.timeInterval: @@ -199,6 +211,14 @@ struct PumpView: View { return Color.loopGreen } } + + private var timerColorSecondary: Color { + if activatedAtDate != nil { + return Color.gray + } + + return Color.yellow + } } // #Preview("message") { diff --git a/Trio/Sources/Modules/Home/View/HomeRootView.swift b/Trio/Sources/Modules/Home/View/HomeRootView.swift index e4ae94f97ec..a6e33f7850d 100644 --- a/Trio/Sources/Modules/Home/View/HomeRootView.swift +++ b/Trio/Sources/Modules/Home/View/HomeRootView.swift @@ -1,8 +1,31 @@ import CoreData +import CoreHaptics import SpriteKit import SwiftDate import SwiftUI import Swinject +import UnionTabView + +enum HomeTab: Int, CaseIterable, Hashable { + case main = 0 + case history = 1 + case adjustments = 2 + case settings = 3 + case plus = 4 +} + +struct TabCenterPreferenceKey: PreferenceKey { + static var defaultValue: Anchor? = nil + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = nextValue() ?? value + } +} + +struct Haptic: Hashable { + var intensity: CGFloat + var sharpness: CGFloat + var interval: CGFloat +} struct TimePicker: Identifiable { var active: Bool @@ -15,14 +38,26 @@ extension Home { let resolver: Resolver let safeAreaSize: CGFloat = 0.08 + // Explicit initializer so this view can be constructed from other files/modules. + init(resolver: Resolver) { + self.resolver = resolver + } + @Environment(\.managedObjectContext) var moc @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState + @State private var engine: CHHapticEngine? + private var haptics: [Haptic] = [ + Haptic(intensity: 0.5, sharpness: 0.5, interval: 0.0), + Haptic(intensity: 0.7, sharpness: 0.2, interval: 0.3) + ] + @State var state = StateModel() @State var settingsPath = NavigationPath() @State var isStatusPopupPresented = false + @State var ghostTab: HomeTab? = nil @State var showCancelAlert = false @State var showCancelConfirmDialog = false @State var isConfirmStopOverrideShown = false @@ -30,7 +65,7 @@ extension Home { @State var isConfirmStopTempTargetShown = false @State var isMenuPresented = false @State var showTreatments = false - @State var selectedTab: Int = 0 + @State var selectedTab: HomeTab = .main @State var showPumpSelection: Bool = false @State var showCGMSelection: Bool = false @State var notificationsDisabled = false @@ -41,20 +76,25 @@ extension Home { TimePicker(active: false, hours: 24) ] - @FetchRequest(fetchRequest: OverrideStored.fetch( - NSPredicate.lastActiveOverride, - ascending: false, - fetchLimit: 1 - )) var latestOverride: FetchedResults + @FetchRequest( + fetchRequest: OverrideStored.fetch( + NSPredicate.lastActiveOverride, + ascending: false, + fetchLimit: 1 + ) + ) var latestOverride: FetchedResults - @FetchRequest(fetchRequest: TempTargetStored.fetch( - NSPredicate.lastActiveTempTarget, - ascending: false, - fetchLimit: 1 - )) var latestTempTarget: FetchedResults + @FetchRequest( + fetchRequest: TempTargetStored.fetch( + NSPredicate.lastActiveTempTarget, + ascending: false, + fetchLimit: 1 + ) + ) var latestTempTarget: FetchedResults var bolusProgressFormatter: NumberFormatter { - let fractionDigits: Int = switch state.settingsManager.preferences.bolusIncrement { + let fractionDigits: Int = + switch state.settingsManager.preferences.bolusIncrement { case 0.1: 1 case 0.025: 3 default: 2 @@ -66,7 +106,8 @@ extension Home { formatter.maximumFractionDigits = fractionDigits formatter.minimumFractionDigits = fractionDigits formatter.allowsFloats = true - formatter.roundingIncrement = Double(state.settingsManager.preferences.bolusIncrement) as NSNumber + formatter.roundingIncrement = + Double(state.settingsManager.preferences.bolusIncrement) as NSNumber return formatter } @@ -75,7 +116,9 @@ extension Home { formatter.numberStyle = .decimal if state.units == .mmolL { formatter.maximumFractionDigits = 1 - } else { formatter.maximumFractionDigits = 0 } + } else { + formatter.maximumFractionDigits = 0 + } return formatter } @@ -87,6 +130,67 @@ extension Home { } } + func playHaptics(_ haptics: [Haptic]) { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } + + // Ensure engine is started before playing + if engine == nil { + prepareHaptics() + } + + guard let engine else { return } + + // Try to start the engine in case it was stopped + do { + try engine.start() + } catch { + // Engine might already be running, which is fine + } + + var events: [CHHapticEvent] = [] + var currentTime: TimeInterval = 0 + + for h in haptics { + currentTime += TimeInterval(h.interval) + + let event = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + .init(parameterID: .hapticIntensity, value: Float(h.intensity)), + .init(parameterID: .hapticSharpness, value: Float(h.sharpness)) + ], + relativeTime: currentTime + ) + + events.append(event) + } + + do { + let pattern = try CHHapticPattern(events: events, parameters: []) + let player = try engine.makePlayer(with: pattern) + try player.start(atTime: CHHapticTimeImmediate) + } catch { + print("Haptic error: \(error.localizedDescription)") + } + } + + func prepareHaptics() { + guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return } + + do { + engine = try CHHapticEngine() + try engine?.start() + } catch { + print("Engine start error: \(error.localizedDescription)") + } + + engine?.resetHandler = { [weak engine] in + do { try engine?.start() } catch { + print("Engine restart failed: \(error)") + } + } + } + @ViewBuilder func pumpTimezoneView(_ badgeImage: UIImage, _ badgeColor: Color) -> some View { HStack { Image(uiImage: badgeImage.withRenderingMode(.alwaysTemplate)) @@ -153,6 +257,7 @@ extension Home { reservoir: state.reservoir, name: state.pumpName, expiresAtDate: state.pumpExpiresAtDate, + activatedAtDate: state.pumpActivatedAtDate, timerDate: state.timerDate, pumpStatusHighlightMessage: state.pumpStatusHighlightMessage, battery: state.batteryFromPersistence @@ -182,7 +287,9 @@ extension Home { } rate = scheduledRate } else { - guard let lastTempBasal = state.tempBasals.last?.tempBasal, let tempRate = lastTempBasal.rate else { + guard let lastTempBasal = state.tempBasals.last?.tempBasal, + let tempRate = lastTempBasal.rate + else { return nil } if apsManager.isManualTempBasal { @@ -195,8 +302,8 @@ extension Home { } let rateString = Formatter.decimalFormatterWithThreeFractionDigits.string(from: rate) ?? "0" - return rateString + String(localized: " U/hr", comment: "Unit per hour with space") + - manualBasalString + return rateString + String(localized: " U/hr", comment: "Unit per hour with space") + + manualBasalString } // Returns the scheduled basal rate for the current time based on the saved basal scheduled. @@ -231,8 +338,12 @@ extension Home { var target = (latestOverride.target ?? 0) as Decimal target = unit == .mmolL ? target.asMmolL : target - var targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + unit - .rawValue + var targetString = + target == 0 + ? "" + : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + + unit + .rawValue if tempTargetString != nil { targetString = "" } @@ -241,7 +352,9 @@ extension Home { let addedMinutes = Int(truncating: duration) let date = latestOverride.date ?? Date() let newDuration = max( - Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes), + Decimal( + Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes + ), 0 ) let indefinite = latestOverride.indefinite @@ -262,13 +375,18 @@ extension Home { } } - let smbScheduleString = latestOverride - .smbIsScheduledOff && ((latestOverride.start?.stringValue ?? "") != (latestOverride.end?.stringValue ?? "")) - ? " \(formatTimeRange(start: latestOverride.start?.stringValue, end: latestOverride.end?.stringValue))" - : "" + let smbScheduleString = + latestOverride + .smbIsScheduledOff + && ((latestOverride.start?.stringValue ?? "") != (latestOverride.end?.stringValue ?? "")) + ? " \(formatTimeRange(start: latestOverride.start?.stringValue, end: latestOverride.end?.stringValue))" + : "" - let smbToggleString = latestOverride.smbIsOff || latestOverride - .smbIsScheduledOff ? String(localized: "SMBs Off\(smbScheduleString)") : "" + let smbToggleString = + latestOverride.smbIsOff + || latestOverride + .smbIsScheduledOff + ? String(localized: "SMBs Off\(smbScheduleString)") : "" var smbMinuteString: String = "" var uamMinuteString: String = "" @@ -277,20 +395,25 @@ extension Home { if let smbMinutes = latestOverride.smbMinutes, smbMinutes.decimalValue != settingsManager.preferences.maxSMBBasalMinutes { - smbMinuteString = "SMB\u{00A0}\(smbMinutes)\u{00A0}" + - String(localized: "m", comment: "Abbreviation for Minutes") + smbMinuteString = + "SMB\u{00A0}\(smbMinutes)\u{00A0}" + + String(localized: "m", comment: "Abbreviation for Minutes") } if let uamMinutes = latestOverride.uamMinutes, uamMinutes.decimalValue != settingsManager.preferences.maxUAMSMBBasalMinutes { - uamMinuteString = "UAM\u{00A0}\(uamMinutes)\u{00A0}" + - String(localized: "m", comment: "Abbreviation for Minutes") + uamMinuteString = + "UAM\u{00A0}\(uamMinutes)\u{00A0}" + + String(localized: "m", comment: "Abbreviation for Minutes") } } - let components = [durationString, percentString, targetString, smbToggleString, smbMinuteString, uamMinuteString] - .filter { !$0.isEmpty } + let components = [ + durationString, percentString, targetString, smbToggleString, smbMinuteString, + uamMinuteString + ] + .filter { !$0.isEmpty } return components.isEmpty ? nil : components.joined(separator: ", ") } @@ -302,29 +425,37 @@ extension Home { let addedMinutes = Int(truncating: duration ?? 0) let date = latestTempTarget.date ?? Date() let newDuration = max( - Decimal(Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes), + Decimal( + Date().distance(to: date.addingTimeInterval(addedMinutes.minutes.timeInterval)).minutes + ), 0 ) var durationString = "" var percentageString = "" var target = (latestTempTarget.target ?? 100) as Decimal // Use TempTargetCalculations to get effective HBT (handles both custom and auto-adjusted standard TT) - let effectiveHBT = TempTargetCalculations.computeEffectiveHBT( - tempTargetHalfBasalTarget: latestTempTarget.halfBasalTarget?.decimalValue, - settingHalfBasalTarget: state.settingHalfBasalTarget, - target: target, - autosensMax: state.autosensMax - ) ?? state.settingHalfBasalTarget + let effectiveHBT = + TempTargetCalculations.computeEffectiveHBT( + tempTargetHalfBasalTarget: latestTempTarget.halfBasalTarget?.decimalValue, + settingHalfBasalTarget: state.settingHalfBasalTarget, + target: target, + autosensMax: state.autosensMax + ) ?? state.settingHalfBasalTarget var showPercentage = false - if target > 100, state.isExerciseModeActive || state.highTTraisesSens { showPercentage = true } + if target > 100, state.isExerciseModeActive || state.highTTraisesSens { + showPercentage = true + } if target < 100, state.lowTTlowersSens, state.autosensMax > 1 { showPercentage = true } if showPercentage { percentageString = " \(Int(TempTargetCalculations.computeAdjustedPercentage(halfBasalTarget: effectiveHBT, target: target, autosensMax: state.autosensMax)))%" } target = state.units == .mmolL ? target.asMmolL : target - let targetString = target == 0 ? "" : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + - state.units.rawValue + percentageString + let targetString = + target == 0 + ? "" + : (fetchedTargetFormatter.string(from: target as NSNumber) ?? "") + " " + + state.units.rawValue + percentageString if newDuration >= 1 { durationString = @@ -355,8 +486,7 @@ extension Home { Group { if button.active { Text( - button.hours.description + "\u{00A0}" + - String(localized: "h", comment: "h") + button.hours.description + "\u{00A0}" + String(localized: "h", comment: "h") ) } else { Text(button.hours.description) @@ -368,9 +498,12 @@ extension Home { .padding(.horizontal, 10) .foregroundColor( button - .active ? (colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) : buttonColor + .active + ? (colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white) : buttonColor + ) + .background( + button.active ? buttonColor.opacity(colorScheme == .dark ? 1 : 0.8) : Color.clear ) - .background(button.active ? buttonColor.opacity(colorScheme == .dark ? 1 : 0.8) : Color.clear) .clipShape(Capsule()) .overlay( Capsule() @@ -468,10 +601,12 @@ extension Home { .font(.callout) .fontWeight(.bold) - Text(state.units == .mgdL ? eventualGlucose.description : eventualGlucose.formattedAsMmolL) - .font(.callout) - .fontWeight(.bold) - .fontDesign(.rounded) + Text( + state.units == .mgdL ? eventualGlucose.description : eventualGlucose.formattedAsMmolL + ) + .font(.callout) + .fontWeight(.bold) + .fontDesign(.rounded) } // aligns the evBG icon exactly with the first pixel of loop status icon .padding(.leading, 12) @@ -496,8 +631,8 @@ extension Home { ( Formatter.decimalFormatterWithTwoFractionDigits .string(from: state.currentIOB as NSNumber) ?? "0" - ) + - String(localized: " U", comment: "Insulin unit") + ) + + String(localized: " U", comment: "Insulin unit") ) .font(.callout).fontWeight(.bold).fontDesign(.rounded) } @@ -509,12 +644,9 @@ extension Home { .font(.callout) .foregroundColor(.loopYellow) Text( - ( - Formatter.decimalFormatterWithTwoFractionDigits.string( - from: NSNumber(value: state.enactedAndNonEnactedDeterminations.first?.cob ?? 0) - ) ?? "0" - ) + - String(localized: " g", comment: "gram of carbs") + (Formatter.decimalFormatterWithTwoFractionDigits.string( + from: NSNumber(value: state.enactedAndNonEnactedDeterminations.first?.cob ?? 0) + ) ?? "0") + String(localized: " g", comment: "gram of carbs") ) .font(.callout).fontWeight(.bold).fontDesign(.rounded) } @@ -579,7 +711,7 @@ extension Home { } } .onTapGesture { - selectedTab = 2 + selectedTab = .adjustments } } @@ -596,7 +728,7 @@ extension Home { } } .onTapGesture { - selectedTab = 2 + selectedTab = .adjustments } } @@ -675,32 +807,33 @@ extension Home { // clear color for the icon .foregroundStyle(Color.clear) }.onTapGesture { - selectedTab = 2 + selectedTab = .adjustments } } @ViewBuilder func adjustmentView(geo: GeometryProxy) -> some View { -// let background = colorScheme == .dark ? Material.ultraThinMaterial.opacity(0.5) : Color.black.opacity(0.2) + // let background = colorScheme == .dark ? Material.ultraThinMaterial.opacity(0.5) : Color.black.opacity(0.2) ZStack { /// rectangle as background RoundedRectangle(cornerRadius: 15) .fill( - (overrideString != nil || tempTargetString != nil) ? - ( - colorScheme == .dark ? - Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : - Color.insulin.opacity(0.1) + (overrideString != nil || tempTargetString != nil) + ? ( + colorScheme == .dark + ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) + : Color.insulin.opacity(0.1) ) : Color.clear // Use clear and add the Material in the background ) .background(colorScheme == .dark ? Color.chart.opacity(0.25) : Color.black.opacity(0.075)) .clipShape(RoundedRectangle(cornerRadius: 15)) .frame(height: geo.size.height * 0.08) .shadow( - color: (overrideString != nil || tempTargetString != nil) ? - ( - colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) : - Color.black.opacity(0.33) + color: (overrideString != nil || tempTargetString != nil) + ? ( + colorScheme == .dark + ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) + : Color.black.opacity(0.33) ) : Color.clear, radius: 3 ) @@ -778,17 +911,19 @@ extension Home { .frame(height: 6) .foregroundColor(.clear) .background( - LinearGradient(colors: [ - Color(red: 0.7215686275, green: 0.3411764706, blue: 1), - Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569), - Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765), - Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961), - Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) - ], startPoint: .leading, endPoint: .trailing) - .mask(alignment: .leading) { - RoundedRectangle(cornerRadius: 15) - .frame(width: geo.size.width * CGFloat(progress)) - } + LinearGradient( + colors: [ + Color(red: 0.7215686275, green: 0.3411764706, blue: 1), + Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569), + Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765), + Color(red: 0.3411764706, green: 0.6666666667, blue: 0.9254901961), + Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902) + ], startPoint: .leading, endPoint: .trailing + ) + .mask(alignment: .leading) { + RoundedRectangle(cornerRadius: 15) + .frame(width: geo.size.width * CGFloat(progress)) + } ) } } @@ -801,23 +936,31 @@ extension Home { let bolusFraction = progress * (bolusTotal as Decimal) let bolusString = (bolusProgressFormatter.string(from: bolusFraction as NSNumber) ?? "0") - + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") + - (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0") + + String( + localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view" + ) + + ( + Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) + ?? "0" + ) + String(localized: " U", comment: "Insulin unit") ZStack { /// rectangle as background RoundedRectangle(cornerRadius: 15) .fill( - colorScheme == .dark ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) : Color + colorScheme == .dark + ? Color(red: 0.03921568627, green: 0.133333333, blue: 0.2156862745) + : Color .insulin .opacity(0.2) ) .clipShape(RoundedRectangle(cornerRadius: 15)) .frame(height: geo.size.height * 0.08) .shadow( - color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) : - Color.black.opacity(0.33), + color: colorScheme == .dark + ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) + : Color.black.opacity(0.33), radius: 3 ) @@ -874,8 +1017,9 @@ extension Home { .frame(height: geo.size.height * safeAreaSize) .coordinateSpace(name: "alertSafetyNotificationsView") .shadow( - color: colorScheme == .dark ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) : - Color.black.opacity(0.33), + color: colorScheme == .dark + ? Color(red: 0.02745098039, green: 0.1098039216, blue: 0.1411764706) + : Color.black.opacity(0.33), radius: 3 ) HStack { @@ -934,7 +1078,9 @@ extension Home { if notificationsDisabled { alertSafetyNotificationsView(geo: geo) } - if let badgeImage = state.pumpStatusBadgeImage, let badgeColor = state.pumpStatusBadgeColor { + if let badgeImage = state.pumpStatusBadgeImage, + let badgeColor = state.pumpStatusBadgeColor + { pumpTimezoneView(badgeImage, badgeColor) .padding(.horizontal, 20) } @@ -970,11 +1116,11 @@ extension Home { if let progress = state.bolusProgress { bolusView(geo: geo, progress) - .padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40)) } else { - adjustmentView(geo: geo).padding(.bottom, UIDevice.adjustPadding(min: nil, max: 40)) + adjustmentView(geo: geo) } } + .padding(.bottom, geo.safeAreaInsets.bottom) .background(appState.trioBackgroundColor(for: colorScheme)) .onReceive( resolver.resolve(AlertPermissionsChecker.self)!.$notificationsDisabled, @@ -992,7 +1138,9 @@ extension Home { @ViewBuilder func mainView() -> some View { GeometryReader { geo in mainViewElements(geo) + .safeAreaPadding(.bottom, UIDevice.adjustPadding(min: 70, max: 80) ?? 70) } + .ignoresSafeArea(.keyboard) .onChange(of: state.hours) { highlightButtons() } @@ -1003,7 +1151,6 @@ extension Home { } .navigationTitle("Home") .navigationBarHidden(true) - .ignoresSafeArea(.keyboard) .blur(radius: state.isLoopStatusPresented ? 3 : 0) .sheet(isPresented: $state.isLoopStatusPresented) { LoopStatusView(state: state) @@ -1017,8 +1164,11 @@ extension Home { Button("Omnipod Eros") { state.addPump(.omnipod) } Button("Omnipod DASH") { state.addPump(.omnipodBLE) } Button("Dana(RS/-i)") { state.addPump(.dana) } + Button("Medtrum Nano") { state.addPump(.medtrum) } Button("Pump Simulator") { state.addPump(.simulator) } - } message: { Text("Select Pump Model") } + } message: { + Text("Select Pump Model") + } .sheet(isPresented: $state.shouldDisplayPumpSetupSheet) { if let pumpManager = state.provider.apsManager.pumpManager { PumpConfig.PumpSettingsView( @@ -1083,64 +1233,136 @@ extension Home { } @ViewBuilder func tabBar() -> some View { - ZStack(alignment: .bottom) { - TabView(selection: $selectedTab) { - let carbsRequiredBadge: String? = { - guard let carbsRequired = state.enactedAndNonEnactedDeterminations.first?.carbsRequired, - state.showCarbsRequiredBadge - else { - return nil - } - let carbsRequiredDecimal = Decimal(carbsRequired) - if carbsRequiredDecimal > state.settingsManager.settings.carbsRequiredThreshold { - let numberAsNSNumber = NSDecimalNumber(decimal: carbsRequiredDecimal) - return (Formatter.decimalFormatterWithTwoFractionDigits.string(from: numberAsNSNumber) ?? "") + " g" + let carbsRequiredBadge: String? = { + guard let carbsRequired = state.enactedAndNonEnactedDeterminations.first?.carbsRequired, + state.showCarbsRequiredBadge + else { + return nil + } + let carbsRequiredDecimal = Decimal(carbsRequired) + if carbsRequiredDecimal > state.settingsManager.settings.carbsRequiredThreshold { + let numberAsNSNumber = NSDecimalNumber(decimal: carbsRequiredDecimal) + return + (Formatter.decimalFormatterWithTwoFractionDigits.string(from: numberAsNSNumber) ?? "") + + " g" + } + return nil + }() + + let selectionBinding = Binding( + get: { ghostTab ?? selectedTab }, + set: { newValue in + if newValue == .plus { + ghostTab = .plus + withAnimation(.spring(response: 0, dampingFraction: 0)) { + ghostTab = nil } - return nil - }() - - NavigationStack { mainView() } - .tabItem { Label("Main", systemImage: "chart.xyaxis.line") } - .badge(carbsRequiredBadge).tag(0) - - NavigationStack { History.RootView(resolver: resolver) } - .tabItem { Label("History", systemImage: historySFSymbol) }.tag(1) - - Spacer() + } else { + selectedTab = newValue + ghostTab = nil + } + } + ) - NavigationStack { Adjustments.RootView(resolver: resolver) } - .tabItem { - Label( - "Adjustments", - systemImage: "slider.horizontal.2.gobackward" - ) }.tag(2) + ZStack(alignment: .bottom) { + UnionTabView( + selection: selectionBinding, tabs: [.main, .history, .plus, .adjustments, .settings] + ) { + NavigationStack { + mainView() + } + .badge(carbsRequiredBadge) + .unionTab(HomeTab.main) + .transaction { $0.animation = nil } + + NavigationStack { + History.RootView(resolver: resolver) + .safeAreaPadding(.bottom, UIDevice.adjustPadding(min: 70, max: 80) ?? 70) + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .unionTab(HomeTab.history) + .transaction { $0.animation = nil } + + Color.clear + .unionTab(HomeTab.plus) + .disabled(true) + .allowsHitTesting(false) + .transaction { $0.animation = nil } + + NavigationStack { + Adjustments.RootView(resolver: resolver) + .safeAreaPadding(.bottom, UIDevice.adjustPadding(min: 70, max: 80) ?? 70) + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .unionTab(HomeTab.adjustments) + .transaction { $0.animation = nil } NavigationStack(path: self.$settingsPath) { - Settings.RootView(resolver: resolver) } - .tabItem { Label( - "Settings", - systemImage: "gear" - ) }.tag(3) + Settings.RootView(resolver: resolver) + .safeAreaPadding(.bottom, UIDevice.adjustPadding(min: 70, max: 80) ?? 70) + .ignoresSafeArea(.keyboard, edges: .bottom) + } + .unionTab(HomeTab.settings) + .transaction { $0.animation = nil } + } item: { tab, _ in + VStack(spacing: 2) { + switch tab { + case .main: + Image(systemName: "chart.xyaxis.line") + .font(.system(size: 23)) + .foregroundStyle(selectedTab == tab ? Color.tabBar : .secondary) + // Text("Main").font(.caption2) + case .history: + Image(systemName: historySFSymbol) + .font(.system(size: 23)) + .foregroundStyle(selectedTab == tab ? Color.tabBar : .secondary) + // Text("History").font(.caption2) + case .plus: + // Dummy placeholder to keep the space, captures center coordinate + Color.clear.frame(width: 44, height: 44) + .anchorPreference(key: TabCenterPreferenceKey.self, value: .center) { $0 } + case .adjustments: + Image(systemName: "slider.horizontal.2.gobackward") + .font(.system(size: 23)) + .foregroundStyle(selectedTab == tab ? Color.tabBar : .secondary) + // Text("Adjustments").font(.caption2) + case .settings: + Image(systemName: "gear") + .font(.system(size: 23)) + .foregroundStyle(selectedTab == tab ? Color.tabBar : .secondary) + // Text("Settings").font(.caption2) + } + } + .allowsHitTesting(tab != .plus) } .tint(Color.tabBar) - - Button( - action: { - state.showModal(for: .treatmentView) }, - label: { - Image(systemName: "plus.circle.fill") - .font(.system(size: 40)) - .foregroundStyle(Color.tabBar) - .padding(.vertical, 2) - .padding(.horizontal, 24) - } - ) - }.ignoresSafeArea(.keyboard, edges: .bottom).blur(radius: state.waitForSuggestion ? 8 : 0) - .onChange(of: selectedTab) { - if !settingsPath.isEmpty { - settingsPath = NavigationPath() + .overlayPreferenceValue(TabCenterPreferenceKey.self) { anchor in + GeometryReader { proxy in + if let anchor = anchor { + Button( + action: { + state.showModal(for: .treatmentView) + }, + label: { + Image(systemName: "plus.circle.fill") + .font(.system(size: 40)) + .foregroundStyle(Color.tabBar) + .padding(.vertical, 2) + .padding(.horizontal, 24) + } + ) + .position(proxy[anchor]) + } } } + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .blur(radius: state.waitForSuggestion ? 8 : 0) + .onChange(of: selectedTab) { + if !settingsPath.isEmpty { + settingsPath = NavigationPath() + } + } } var body: some View { @@ -1148,46 +1370,15 @@ extension Home { tabBar() if state.waitForSuggestion { - CustomProgressView(text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB")) + CustomProgressView( + text: String(localized: "Updating IOB...", comment: "Progress text when updating IOB") + ) } } } } } -extension UIDevice { - public enum DeviceSize: CGFloat { - case smallDevice = 667 // Height for 4" iPhone SE - case largeDevice = 852 // Height for 6.1" iPhone 15 Pro - } - - @usableFromInline static func adjustPadding( - min: CGFloat? = nil, - max: CGFloat? = nil - ) -> CGFloat? { - if UIScreen.screenHeight > UIDevice.DeviceSize.smallDevice.rawValue { - if UIScreen.screenHeight >= UIDevice.DeviceSize.largeDevice.rawValue { - return max - } else { - return min != nil ? - (max != nil ? max! * (UIScreen.screenHeight / UIDevice.DeviceSize.largeDevice.rawValue) : nil) : nil - } - } else { - return min - } - } -} - -extension UIScreen { - static var screenHeight: CGFloat { - UIScreen.main.bounds.height - } - - static var screenWidth: CGFloat { - UIScreen.main.bounds.width - } -} - /// Checks if the device is using a 24-hour time format. func is24HourFormat() -> Bool { let formatter = DateFormatter() @@ -1210,7 +1401,8 @@ func formatHrMin(_ durationInMinutes: Int) -> String { case let (h, 0): return "\(h)\u{00A0}" + String(localized: "h", comment: "h") default: - return hours.description + "\u{00A0}" + String(localized: "h", comment: "h") + "\u{00A0}" + minutes + return hours.description + "\u{00A0}" + String(localized: "h", comment: "h") + "\u{00A0}" + + minutes .description + "\u{00A0}" + String(localized: "m", comment: "Abbreviation for Minutes") } } @@ -1231,13 +1423,19 @@ func formatTimeRange(start: String?, end: String?) -> String { formatter.dateFormat = "HH" if let startHour = Int(start), let endHour = Int(end) { - let startDate = Calendar.current.date(bySettingHour: startHour, minute: 0, second: 0, of: Date()) ?? Date() - let endDate = Calendar.current.date(bySettingHour: endHour, minute: 0, second: 0, of: Date()) ?? Date() + let startDate = + Calendar.current.date(bySettingHour: startHour, minute: 0, second: 0, of: Date()) ?? Date() + let endDate = + Calendar.current.date(bySettingHour: endHour, minute: 0, second: 0, of: Date()) ?? Date() // Customize the format to "2p" or "2a" formatter.dateFormat = "ha" - let startFormatted = formatter.string(from: startDate).lowercased().replacingOccurrences(of: "m", with: "") - let endFormatted = formatter.string(from: endDate).lowercased().replacingOccurrences(of: "m", with: "") + let startFormatted = formatter.string(from: startDate).lowercased().replacingOccurrences( + of: "m", with: "" + ) + let endFormatted = formatter.string(from: endDate).lowercased().replacingOccurrences( + of: "m", with: "" + ) return "\(startFormatted)-\(endFormatted)" } else { diff --git a/Trio/Sources/Modules/Main/MainStateModel.swift b/Trio/Sources/Modules/Main/MainStateModel.swift index 3485f958e5c..9d4a3a1260b 100644 --- a/Trio/Sources/Modules/Main/MainStateModel.swift +++ b/Trio/Sources/Modules/Main/MainStateModel.swift @@ -283,6 +283,14 @@ extension Main { self.router.mainSecondaryModalView.send(nil) } .store(in: &lifetime) + + // Subscribe to BarcodeScanner shortcut notification + Foundation.NotificationCenter.default.publisher(for: .openBarcode) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.router.mainModalScreen.send(.treatmentWithScanner) + } + .store(in: &lifetime) } } } diff --git a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift index df2ed16d683..12c260df82a 100644 --- a/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift +++ b/Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift @@ -3,6 +3,7 @@ import DanaKit import FirebaseCrashlytics import Foundation import LoopKit +import MedtrumKit import MinimedKit import Observation import OmniBLE @@ -120,6 +121,8 @@ extension Onboarding { defaultOption = .omnipodDash } else if pumpManager is OmnipodPumpManager { defaultOption = .omnipodEros + } else if pumpManager is MedtrumPumpManager { + defaultOption = .medtrum } else if pumpManager is DanaKitPumpManager { defaultOption = .dana } else if pumpManager is MinimedPumpManager { @@ -165,6 +168,8 @@ extension Onboarding { return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour) case .omnipodEros: return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour) + case .medtrum: + return PickerSetting(value: 0.1, step: 0.05, min: 0.05, max: 30, type: .insulinUnitPerHour) case .none: // same as dash, as that is the fallback return PickerSetting(value: 0.1, step: 0.05, min: 0, max: 30, type: .insulinUnitPerHour) diff --git a/Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift b/Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift index 601b1e6a258..57652906183 100644 --- a/Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift +++ b/Trio/Sources/Modules/Onboarding/View/OnboardingRootView.swift @@ -597,7 +597,8 @@ struct OnboardingNavigationButtons: View { case .dana, .minimed: currentAutosensSubstep = .rewindResetsAutosens - case .omnipodDash, + case .medtrum, + .omnipodDash, .omnipodEros: currentAutosensSubstep = .autosensMax } diff --git a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift index a9ef4024dbc..da259b06f64 100644 --- a/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift +++ b/Trio/Sources/Modules/Onboarding/View/OnboardingSteps/AlgorithmSettings/AlgorithmSettingsSubstepView.swift @@ -21,7 +21,8 @@ struct AlgorithmSettingsSubstepView { @ObservationIgnored @Persisted(key: "UserNotificationsManager.snoozeUntilDate") var snoozeUntilDate: Date = .distantPast - @ObservationIgnored @Injected() var glucoseStogare: GlucoseStorage! + @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage! + @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager! + @ObservationIgnored @Injected() var broadcaster: Broadcaster! var alarm: GlucoseAlarm? override func subscribe() { - alarm = glucoseStogare.alarm + alarm = glucoseStorage.alarm + broadcaster.register(SnoozeObserver.self, observer: self) } + + func unsubscribe() { + broadcaster.unregister(SnoozeObserver.self, observer: self) + } + + // Add validation helper inside the class + private func validateSnoozeDuration(_ duration: TimeInterval) -> Bool { + // Only allow durations matching our defined actions + NotificationResponseAction.allCases + .map(\.duration) + .contains(duration) + } + + @MainActor func applySnooze(_ duration: TimeInterval) async { + // Allow any duration chosen in the Snooze UI, while keeping validation for quick actions elsewhere. + snoozeUntilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast + alarm = glucoseStorage.alarm + await notificationsManager.applySnooze(for: duration) + } + } +} + +extension Snooze.StateModel: SnoozeObserver { + func snoozeDidChange(_ untilDate: Date) { + snoozeUntilDate = untilDate + alarm = glucoseStorage.alarm } } diff --git a/Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift b/Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift index c4bcd603811..6ad4641cf30 100644 --- a/Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift +++ b/Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift @@ -14,29 +14,12 @@ extension Snooze { @State private var snoozeDescription = "nothing to see here" private var pickerTimes: [TimeInterval] { - var arr: [TimeInterval] = [] - - let mins10 = 0.166_67 - let mins20 = mins10 * 2 - let mins30 = mins10 * 3 - // let mins40 = mins10 * 4 - - for hr in 0 ..< 2 { - for min in [0.0, mins20, mins20 * 2] { - arr.append(TimeInterval(hours: Double(hr) + min)) - } - } - for hr in 2 ..< 4 { - for min in [0.0, mins30] { - arr.append(TimeInterval(hours: Double(hr) + min)) - } - } - - for hr in 4 ... 8 { - arr.append(TimeInterval(hours: Double(hr))) - } - - return arr + [ + TimeInterval(minutes: 20), // 20 minutes + TimeInterval(hours: 1), // 1 hour + TimeInterval(hours: 3), // 3 hours + TimeInterval(hours: 6) // 6 hours + ] } private var formatter: DateComponentsFormatter { @@ -53,7 +36,7 @@ extension Snooze { } private func formatInterval(_ interval: TimeInterval) -> String { - formatter.string(from: interval)! + formatter.string(from: interval) ?? "" } func getSnoozeDescription() -> String { @@ -85,12 +68,16 @@ extension Snooze { VStack(alignment: .leading) { Button { let interval = pickerTimes[selectedInterval] - let snoozeFor = formatter.string(from: interval)! + let snoozeFor = formatInterval(interval) let untilDate = Date() + interval - state.snoozeUntilDate = untilDate < Date() ? .distantPast : untilDate - debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))") - snoozeDescription = getSnoozeDescription() - state.hideModal() + + Task { @MainActor [weak state] in + guard let state = state else { return } + await state.applySnooze(interval) + debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))") + snoozeDescription = getSnoozeDescription() + state.hideModal() + } } label: { Text("Click to Snooze Alerts") .padding() @@ -120,11 +107,20 @@ extension Snooze { .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme)) .navigationBarTitle("Snooze Alerts") .navigationBarTitleDisplayMode(.automatic) - .navigationBarItems(trailing: Button("Close", action: state.hideModal)) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Close") { + state.hideModal() + } + } + } .onAppear { configureView() snoozeDescription = getSnoozeDescription() } + .onDisappear { + state.unsubscribe() + } } } } diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift index 59344c9be6d..b460bfb5575 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseSectorChart.swift @@ -343,7 +343,7 @@ struct GlucoseSectorChart: View { formatPercentage(Decimal(low) / total * 100) ), ( - String(localized: "Very Low (<\(Decimal(54).formatted(for: units))"), + String(localized: "Very Low (<\(Decimal(54).formatted(for: units)))"), formatPercentage(Decimal(veryLow) / total * 100) ), (String(localized: "Average"), average.formatted(for: units)), diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift index 800d2aa8663..4ef55a0ecf0 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift @@ -272,9 +272,9 @@ struct BolusStatsView: View { AxisGridLine() } case .total: - // Only show every other month + // Show start of every month let day = Calendar.current.component(.day, from: date) - if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 { + if day == 1 { AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true) .font(.footnote) AxisGridLine() diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift index 3ac856f89c3..03f684258fd 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift @@ -224,8 +224,8 @@ struct TotalDailyDoseChart: View { AxisGridLine() } case .total: - // Only show every other month - if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 { + // Show start of every month + if day == 1 { AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true) .font(.footnote) AxisGridLine() diff --git a/Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift b/Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift index 761b5e22c57..d707efacfcf 100644 --- a/Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift +++ b/Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift @@ -254,9 +254,9 @@ struct MealStatsView: View { AxisGridLine() } case .total: - // Only show every other month + // Show start of every month let day = Calendar.current.component(.day, from: date) - if day == 1 && Calendar.current.component(.month, from: date) % 2 == 1 { + if day == 1 { AxisValueLabel(format: StatChartUtils.dateFormat(for: selectedInterval), centered: true) .font(.footnote) AxisGridLine() diff --git a/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift b/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift index 77a950c5b0e..dc0ab11101a 100644 --- a/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift +++ b/Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift @@ -100,6 +100,12 @@ extension Treatments { var maxFat: Decimal = 0 var maxProtein: Decimal = 0 + // Scanned amounts for blue labels + var scannedCarbs: Decimal = 0 + var scannedFat: Decimal = 0 + var scannedProtein: Decimal = 0 + var barcodeScannerOnlyCarbs: Bool = false + var id_: String = "" var summary: String = "" @@ -302,6 +308,7 @@ extension Treatments { useFPUconversion = settingsManager.settings.useFPUconversion isSmoothingEnabled = settingsManager.settings.smoothGlucose glucoseColorScheme = settingsManager.settings.glucoseColorScheme + barcodeScannerOnlyCarbs = settings.settings.barcodeScannerOnlyCarbs } private func getCurrentSettingValue(for type: SettingType) async { @@ -341,7 +348,9 @@ extension Treatments { let entryEndTime: Date if index < entries.count - 1 { if let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start) { - let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime) + let nextEntryComponents = calendar.dateComponents( + [.hour, .minute, .second], from: nextEntryTime + ) entryEndTime = calendar.date( bySettingHour: nextEntryComponents.hour!, minute: nextEntryComponents.minute!, @@ -394,8 +403,10 @@ extension Treatments { // Check if this is a backdated entry by comparing with the default date using a tolerance let isBackdated = abs(date.timeIntervalSince(defaultDate)) > 1.0 + let combinedCarbs = carbs + scannedCarbs + let result = await bolusCalculationManager.handleBolusCalculation( - carbs: carbs, + carbs: combinedCarbs, useFattyMealCorrection: useFattyMealCorrectionFactor, useSuperBolus: useSuperBolus, lastLoopDate: apsManager.lastLoopDate, @@ -429,9 +440,9 @@ extension Treatments { self.addButtonPressed = true } let isInsulinGiven = amount > 0 - let isCarbsPresent = carbs > 0 - let isFatPresent = fat > 0 - let isProteinPresent = protein > 0 + let isCarbsPresent = carbs > 0 || scannedCarbs > 0 + let isFatPresent = fat > 0 || scannedFat > 0 + let isProteinPresent = protein > 0 || scannedProtein > 0 if isCarbsPresent || isFatPresent || isProteinPresent { await saveMeal() @@ -526,7 +537,8 @@ extension Treatments { case .passcodeNotSet: return String( - localized: "Authentication requires a device passcode. Please set one in iOS Settings > Face ID & Passcode." + localized: + "Authentication requires a device passcode. Please set one in iOS Settings > Face ID & Passcode." ) case .biometryNotAvailable: @@ -542,13 +554,15 @@ extension Treatments { case .biometryLockout, .touchIDLockout: return String( - localized: "Biometric authentication is locked due to multiple failed attempts. Please unlock your device using your passcode." + localized: + "Biometric authentication is locked due to multiple failed attempts. Please unlock your device using your passcode." ) case .biometryDisconnected, .biometryNotPaired: return String( - localized: "Biometric accessory is missing or not connected. Please reconnect it and try again." + localized: + "Biometric accessory is missing or not connected. Please reconnect it and try again." ) default: @@ -624,27 +638,30 @@ extension Treatments { func saveMeal() async { do { - guard carbs > 0 || fat > 0 || protein > 0 else { return } + let totalCarbs = min(carbs + scannedCarbs, maxCarbs) + let totalFat = barcodeScannerOnlyCarbs ? 0 : min(fat + scannedFat, maxFat) + let totalProtein = barcodeScannerOnlyCarbs ? 0 : min(protein + scannedProtein, maxProtein) + + guard totalCarbs > 0 || totalFat > 0 || totalProtein > 0 else { return } await MainActor.run { - self.carbs = min(self.carbs, self.maxCarbs) - self.fat = min(self.fat, self.maxFat) - self.protein = min(self.protein, self.maxProtein) self.id_ = UUID().uuidString } - let carbsToStore = [CarbsEntry( - id: id_, - createdAt: now, - actualDate: date, - carbs: carbs, - fat: fat, - protein: protein, - note: note, - enteredBy: CarbsEntry.local, - isFPU: false, - fpuID: fat > 0 || protein > 0 ? UUID().uuidString : nil - )] + let carbsToStore = [ + CarbsEntry( + id: id_, + createdAt: now, + actualDate: date, + carbs: totalCarbs, + fat: totalFat, + protein: totalProtein, + note: note, + enteredBy: CarbsEntry.local, + isFPU: false, + fpuID: totalFat > 0 || totalProtein > 0 ? UUID().uuidString : nil + ) + ] try await carbsStorage.storeCarbs(carbsToStore, areFetchedFromRemote: false) // only perform determine basal sync if the user doesn't use the pump bolus, otherwise the enact bolus func in the APSManger does a sync @@ -698,6 +715,35 @@ extension Treatments { func addToSummation() { summation.append(selection?.dish ?? "") } + + func addScannedAmounts(carbs: Decimal, fat: Decimal, protein: Decimal, note: String) { + Task { @MainActor in + debug( + .bolusState, + "addScannedAmounts called with carbs=\(carbs) fat=\(fat) protein=\(protein) note=\(note)" + ) + self.carbs += carbs + self.fat += fat + self.protein += protein + + scannedCarbs += carbs + scannedFat += fat + scannedProtein += protein + + debug( + .bolusState, + "new totals: carbs=\(self.carbs) scannedCarbs=\(scannedCarbs) fat=\(self.fat) protein=\(self.protein)" + ) + + if !note.isEmpty { + if self.note.isEmpty { + self.note = note + } else { + self.note += ", " + note + } + } + } + } } } @@ -811,7 +857,9 @@ extension Treatments.StateModel { // Calculate delta using newest and oldest readings within 20-minute window let delta: Decimal - if let newestInWindow = recentObjects.first?.glucose, let oldestInWindow = recentObjects.last?.glucose { + if let newestInWindow = recentObjects.first?.glucose, + let oldestInWindow = recentObjects.last?.glucose + { // Newest is at index 0, oldest is at the last index delta = Decimal(newestInWindow) - Decimal(oldestInWindow) } else { @@ -907,22 +955,26 @@ extension Treatments.StateModel { minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal lastLoopDate = apsManager.lastLoopDate as Date? insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal - target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal + target = + (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal cob = mostRecentDetermination.cob as Int16 iob = (mostRecentDetermination.iob ?? 0) as Decimal basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal - carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal + carbRatio = + (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal insulinCalculated = await calculateInsulin() } } } extension Treatments.StateModel { - @MainActor func updateForecasts(with forecastData: Determination? = nil) async { - guard isActive else { + @MainActor func updateForecasts(with forecastData: Determination? = nil, force: Bool = false) + async + { + guard isActive || force else { + debug(.bolusState, "updateForecasts not fired") return - debug(.bolusState, "updateForecasts not fired") } debug(.bolusState, "updateForecasts fired") @@ -933,7 +985,7 @@ extension Treatments.StateModel { simulatedDetermination = await Task { [self] in debug(.bolusState, "calling simulateDetermineBasal to get forecast data") return await apsManager.simulateDetermineBasal( - simulatedCarbsAmount: carbs, + simulatedCarbsAmount: carbs + scannedCarbs, simulatedBolusAmount: amount, simulatedCarbsDate: date ) diff --git a/Trio/Sources/Modules/Treatments/View/FoodSearchResultRow.swift b/Trio/Sources/Modules/Treatments/View/FoodSearchResultRow.swift new file mode 100644 index 00000000000..9bee58cac52 --- /dev/null +++ b/Trio/Sources/Modules/Treatments/View/FoodSearchResultRow.swift @@ -0,0 +1,88 @@ +import SwiftUI + +extension Treatments { + /// A compact row view for displaying food search results + struct FoodSearchResultRow: View { + let item: BarcodeScanner.FoodItem + let onAdd: () -> Void + + var body: some View { + Button(action: onAdd) { + HStack(spacing: 12) { + // Product image + productImage + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // Product info + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(1) + + HStack(spacing: 8) { + if let brand = item.brand { + Text(brand) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if let carbs = item.nutriments.carbohydratesPer100g { + Text("\(carbs, specifier: "%.1f")g carbs/100g") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + + Spacer() + + // Add button indicator + Image(systemName: "plus.circle.fill") + .font(.title3) + .foregroundStyle(.blue) + } + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } + + @ViewBuilder private var productImage: some View { + switch item.imageSource { + case let .url(url): + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFill() + case .failure: + imagePlaceholder + default: + ProgressView() + .frame(width: 44, height: 44) + } + } + + case let .image(uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + + case .none: + imagePlaceholder + } + } + + private var imagePlaceholder: some View { + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.2)) + .overlay( + Image(systemName: "fork.knife") + .font(.caption) + .foregroundStyle(.secondary) + ) + } + } +} diff --git a/Trio/Sources/Modules/Treatments/View/ForecastChart.swift b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift index 9a57250a490..3ca52ebd6fb 100644 --- a/Trio/Sources/Modules/Treatments/View/ForecastChart.swift +++ b/Trio/Sources/Modules/Treatments/View/ForecastChart.swift @@ -40,36 +40,39 @@ struct ForecastChart: View { let isBackdated = abs(state.date.timeIntervalSince(state.defaultDate)) > 1.0 // When backdated, display no carbs as this label is only supposed to show current entered carbs - let displayedCarbs = isBackdated ? 0 : state.carbs + // Use combined carbs (user entered + scanned) so chart reflects scanned items immediately + let displayedCarbs = isBackdated ? 0 : (state.carbs + state.scannedCarbs) + + let totalCarbs = displayedCarbs + Decimal(state.cob) + let totalInsulin = state.amount + state.iob return HStack { HStack { - Image(systemName: "fork.knife") - Text("\(displayedCarbs.description) g") + Image(systemName: "syringe.fill") + Text( + "\(Formatter.bolusFormatter.string(from: totalInsulin as NSNumber) ?? totalInsulin.description) " + ) + Text(String(localized: "U", comment: "Insulin unit")) } .font(.footnote) - .foregroundStyle(.orange) + .foregroundStyle(.blue) .padding(8) .background { RoundedRectangle(cornerRadius: 10) - .fill(Color.orange.opacity(0.2)) + .fill(Color.blue.opacity(0.2)) } Spacer() HStack { - Image(systemName: "syringe.fill") - Text( - "\(Formatter.bolusFormatter.string(from: state.amount as NSNumber) ?? state.amount.description) " - ) + Text(String(localized: "U", comment: "Insulin unit")) + Image(systemName: "fork.knife") + Text("\(Double(truncating: totalCarbs as NSNumber), specifier: "%.1f") g") } - .font(.footnote) - .foregroundStyle(.blue) + .foregroundStyle(.orange) .padding(8) .background { RoundedRectangle(cornerRadius: 10) - .fill(Color.blue.opacity(0.2)) + .fill(Color.orange.opacity(0.2)) } Spacer() diff --git a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift index 2c0675053a2..9fa485a7299 100644 --- a/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift +++ b/Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift @@ -16,8 +16,9 @@ extension Treatments { @FocusState private var focusedField: FocusedField? let resolver: Resolver + var openWithScanner: Bool = false - @State var state = StateModel() + @StateObject var state = StateModel() @State private var showPresetSheet = false @State private var autofocus: Bool = true @@ -26,6 +27,10 @@ extension Treatments { @State private var debounce: DispatchWorkItem? @State private var showFatProteinOrderBanner = false + // Food search state + @State private var showAllSearchResults = false + @FocusState private var isSearchFocused: Bool + private enum Config { static let dividerHeight: CGFloat = 2 static let spacing: CGFloat = 3 @@ -66,7 +71,9 @@ extension Treatments { private var fractionDigits: Int { if state.units == .mmolL { return 1 - } else { return 0 } + } else { + return 0 + } } /// Handles macro input (carb, fat, protein) in a debounced fashion. @@ -85,36 +92,164 @@ extension Treatments { @ViewBuilder private func proteinAndFat() -> some View { HStack { - HStack { - Text("Fat") - TextFieldWithToolBar( - text: $state.fat, - placeholder: "0", - keyboardType: .numberPad, - numberFormatter: mealFormatter, - showArrows: true, - previousTextField: { focusedField = previousField(from: .fat) }, - nextTextField: { focusedField = nextField(from: .fat) }, - unitsText: String(localized: "g", comment: "Units for carbs") - ) - .focused($focusedField, equals: .fat) + VStack { + HStack { + Text("Protein") + TextFieldWithToolBar( + text: $state.protein, + placeholder: "0", + keyboardType: .numberPad, + numberFormatter: mealFormatter, + showArrows: true, + previousTextField: { focusedField = previousField(from: .protein) }, + nextTextField: { focusedField = nextField(from: .protein) }, + unitsText: String(localized: "g", comment: "Units for carbs") + ) + .focused($focusedField, equals: .protein) + .onChange(of: state.protein) { + handleDebouncedInput() + } + if state.scannedProtein > 0 && !state.settings.settings.barcodeScannerOnlyCarbs { + Text("+ \(Double(truncating: state.scannedProtein as NSNumber), specifier: "%.1f")g") + .font(.caption) + .foregroundStyle(.blue) + } + } } Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10) - HStack { - Text("Protein") - TextFieldWithToolBar( - text: $state.protein, - placeholder: "0", - keyboardType: .numberPad, - numberFormatter: mealFormatter, - showArrows: true, - previousTextField: { focusedField = previousField(from: .protein) }, - nextTextField: { focusedField = nextField(from: .protein) }, - unitsText: String(localized: "g", comment: "Units for carbs") - ) - .focused($focusedField, equals: .protein) + VStack { + HStack { + Text("Fat") + TextFieldWithToolBar( + text: $state.fat, + placeholder: "0", + keyboardType: .numberPad, + numberFormatter: mealFormatter, + showArrows: true, + previousTextField: { focusedField = previousField(from: .fat) }, + nextTextField: { focusedField = nextField(from: .fat) }, + unitsText: String(localized: "g", comment: "Units for carbs") + ) + .focused($focusedField, equals: .fat) + .onChange(of: state.fat) { + handleDebouncedInput() + } + if state.scannedFat > 0 && !state.settings.settings.barcodeScannerOnlyCarbs { + Text("+ \(Double(truncating: state.scannedFat as NSNumber), specifier: "%.1f")g") + .font(.caption) + .foregroundStyle(.blue) + } + } + } + } + } + + @ViewBuilder var foodSearch: some View { + // Food Search & Quick Actions + if state.settings != nil && state.settings.settings.barcodeScannerEnabled { + // Combined search bar with action buttons + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + // Scanner button + Button { + configureAndShowScanner(showList: false) + } label: { + Image(systemName: "barcode.viewfinder") + .font(.title2) + .foregroundStyle(.blue) + } + .buttonStyle(.plain) + + // Search field + BarcodeScanner.ProductSearchField( + searchText: $scannerState.searchQuery, + isFocused: $isSearchFocused, + onSubmit: { + showAllSearchResults = false + scannerState.performFoodSearch() + }, + onClear: { + scannerState.searchQuery = "" + scannerState.searchResults = [] + showAllSearchResults = false + }, + onChange: { + showAllSearchResults = false + } + ) + + // List button + Button { + configureAndShowScanner(showList: true) + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "list.bullet") + .font(.title2) + .foregroundStyle(.blue) + + if !scannerState.scannedProducts.isEmpty { + Text("\(scannerState.scannedProducts.count)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.white) + .padding(4) + .background(Circle().fill(Color.red)) + .offset(x: 8, y: -8) + } + } + } + .buttonStyle(.plain) + } + + // Search results and Spinner + if scannerState.isSearching { + HStack { + Spacer() + ProgressView() + .padding(.vertical, 8) + Spacer() + } + } else if let error = scannerState.searchError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } else if !scannerState.searchResults.isEmpty { + VStack(spacing: 0) { + let displayResults = showAllSearchResults ? scannerState + .searchResults : Array(scannerState.searchResults.prefix(5)) + ForEach(displayResults) { item in + BarcodeScanner.FoodSearchResultRow(item: item) { + addSearchResultToMeal(item) + } + if item.id != displayResults.last?.id { + Divider().opacity(0.3) + } + } + + if scannerState.searchResults.count > 5 { + Button { + withAnimation { + showAllSearchResults.toggle() + } + } label: { + HStack { + Text( + showAllSearchResults ? "Show less" : + "Show \(scannerState.searchResults.count - 5) more results" + ) + .font(.caption.weight(.medium)) + Image(systemName: showAllSearchResults ? "chevron.up" : "chevron.down") + .font(.caption) + } + .foregroundStyle(.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + } + } } } } @@ -123,19 +258,26 @@ extension Treatments { HStack { Text("Carbs") Spacer() - TextFieldWithToolBar( - text: $state.carbs, - placeholder: "0", - keyboardType: .numberPad, - numberFormatter: mealFormatter, - showArrows: true, - previousTextField: { focusedField = previousField(from: .carbs) }, - nextTextField: { focusedField = nextField(from: .carbs) }, - unitsText: String(localized: "g", comment: "Units for carbs") - ) - .focused($focusedField, equals: .carbs) - .onChange(of: state.carbs) { - handleDebouncedInput() + VStack(alignment: .trailing, spacing: 2) { + TextFieldWithToolBar( + text: $state.carbs, + placeholder: "0", + keyboardType: .numberPad, + numberFormatter: mealFormatter, + showArrows: true, + previousTextField: { focusedField = previousField(from: .carbs) }, + nextTextField: { focusedField = nextField(from: .carbs) }, + unitsText: String(localized: "g", comment: "Units for carbs") + ) + .focused($focusedField, equals: .carbs) + .onChange(of: state.carbs) { + handleDebouncedInput() + } + if state.scannedCarbs > 0 { + Text("+ \(Double(truncating: state.scannedCarbs as NSNumber), specifier: "%.1f")g") + .font(.caption) + .foregroundStyle(.blue) + } } } } @@ -185,206 +327,278 @@ extension Treatments { } } - var body: some View { - ZStack(alignment: .center) { - VStack { - List { - Section { - ForecastChart(state: state) - .padding(.vertical) - }.listRowBackground(Color.chart) + @ViewBuilder var inputsView: some View { + VStack { + Spacer() + carbsTextField() - Section { - carbsTextField() + Divider() - if state.useFPUconversion { - proteinAndFat() + if state.useFPUconversion { + proteinAndFat() + Divider() + } - if showFatProteinOrderBanner { - HStack { - Image(systemName: "arrow.left.arrow.right") - Text("The order of Fat and Protein inputs has changed.").font(.callout) - Spacer() - Button { - PropertyPersistentFlags.shared.hasSeenFatProteinOrderChange = true - withAnimation { showFatProteinOrderBanner = false } - } label: { - Image(systemName: "xmark.circle.fill") - } - .buttonStyle(.plain) - } - .listRowBackground(Color.orange.opacity(0.75)) - .transition(.opacity) + // Time + HStack { + Image(systemName: "clock") + + Spacer() + if !pushed { + Button { + pushed = true + } label: { Text("Now") }.buttonStyle(.borderless).foregroundColor(.secondary) + .padding(.trailing, 5) + } else { + Button { state.date = state.date.addingTimeInterval(-15.minutes.timeInterval) } + label: { Image(systemName: "minus.circle") }.tint(.blue).buttonStyle(.borderless) + + DatePicker( + "Time", + selection: $state.date, + displayedComponents: [.hourAndMinute] + ).controlSize(.mini) + .labelsHidden() + .onChange(of: state.date) { _, _ in + // Trigger simulation when date changes to update forecasts for backdated carbs + Task { + // `updateForecasts()` does update the `simulatedDetermination` of type `Determination?` var on the main thread, so I can use this to pass its cob value into the bolus calc manager + await state.updateForecasts() + state.insulinCalculated = await state.calculateInsulin() } } + Button { + state.date = state.date.addingTimeInterval(15.minutes.timeInterval) + } + label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless) + } + } - // Time - HStack { - // Semi-hacky workaround to make sure the List renders the horizontal divider properly between the `Time` and `Note` rows within the Section - HStack { - Text("") - Image(systemName: "clock").padding(.leading, -7) - } + Divider() - Spacer() - if !pushed { - Button { - pushed = true - } label: { Text("Now") }.buttonStyle(.borderless).foregroundColor(.secondary) - .padding(.trailing, 5) - } else { - Button { state.date = state.date.addingTimeInterval(-15.minutes.timeInterval) } - label: { Image(systemName: "minus.circle") }.tint(.blue).buttonStyle(.borderless) - - DatePicker( - "Time", - selection: $state.date, - displayedComponents: [.hourAndMinute] - ).controlSize(.mini) - .labelsHidden() - .onChange(of: state.date) { _, _ in - // Trigger simulation when date changes to update forecasts for backdated carbs - Task { - // `updateForecasts()` does update the `simulatedDetermination` of type `Determination?` var on the main thread, so I can use this to pass its cob value into the bolus calc manager - await state.updateForecasts() - state.insulinCalculated = await state.calculateInsulin() - } - } - Button { - state.date = state.date.addingTimeInterval(15.minutes.timeInterval) + // Notes + HStack { + Image(systemName: "square.and.pencil") + TextFieldWithToolBarString( + text: $state.note, + placeholder: String(localized: "Note..."), + maxLength: 25 + ) + } + Spacer() + } + .background(Rectangle().fill(Color.chart)) + } + + @ViewBuilder var optionsView: some View { + VStack { + if state.fattyMeals || state.sweetMeals { + Spacer() + HStack(spacing: 10) { + if state.fattyMeals { + Toggle(isOn: $state.useFattyMealCorrectionFactor) { + Text("Reduced Bolus") + } + .toggleStyle(RadioButtonToggleStyle()) + .font(.footnote) + .onChange(of: state.useFattyMealCorrectionFactor) { + Task { + state.insulinCalculated = await state.calculateInsulin() + if state.useFattyMealCorrectionFactor { + state.useSuperBolus = false } - label: { Image(systemName: "plus.circle") }.tint(.blue).buttonStyle(.borderless) } } - - // Notes - HStack { - Image(systemName: "square.and.pencil") - TextFieldWithToolBarString( - text: $state.note, - placeholder: String(localized: "Note..."), - maxLength: 25 - ) + } + if state.sweetMeals { + Toggle(isOn: $state.useSuperBolus) { + Text("Super Bolus") } - }.listRowBackground(Color.chart) - - Section { - if state.fattyMeals || state.sweetMeals { - HStack(spacing: 10) { - if state.fattyMeals { - Toggle(isOn: $state.useFattyMealCorrectionFactor) { - Text("Reduced Bolus") - } - .toggleStyle(RadioButtonToggleStyle()) - .font(.footnote) - .onChange(of: state.useFattyMealCorrectionFactor) { - Task { - state.insulinCalculated = await state.calculateInsulin() - if state.useFattyMealCorrectionFactor { - state.useSuperBolus = false - } - } - } - } - if state.sweetMeals { - Toggle(isOn: $state.useSuperBolus) { - Text("Super Bolus") - } - .toggleStyle(RadioButtonToggleStyle()) - .font(.footnote) - .onChange(of: state.useSuperBolus) { - Task { - state.insulinCalculated = await state.calculateInsulin() - if state.useSuperBolus { - state.useFattyMealCorrectionFactor = false - } - } - } + .toggleStyle(RadioButtonToggleStyle()) + .font(.footnote) + .onChange(of: state.useSuperBolus) { + Task { + state.insulinCalculated = await state.calculateInsulin() + if state.useSuperBolus { + state.useFattyMealCorrectionFactor = false } } } + } + } + Divider() + } - HStack { - HStack { - Text("Recommendation") - Button(action: { - state.showInfo.toggle() - }, label: { - Image(systemName: "info.circle") - }) - .foregroundStyle(.blue) - .buttonStyle(PlainButtonStyle()) - } - Spacer() - Button { - state.amount = state.insulinCalculated - } label: { - HStack { - Text( - formatter - .string(from: Double(state.insulinCalculated) as NSNumber) ?? "" - ) + Spacer() - Text( - String( - localized: - " U", - comment: "Unit in number of units delivered (keep the space character!)" - ) - ).foregroundColor(.secondary) - } - } - .disabled(state.insulinCalculated == 0 || state.amount == state.insulinCalculated) - .buttonStyle(.bordered).padding(.trailing, -10) - } + HStack { + HStack { + Text("Recommendation") + Button(action: { + state.showInfo.toggle() + }, label: { + Image(systemName: "info.circle") + }) + .foregroundStyle(.blue) + .buttonStyle(PlainButtonStyle()) + } + Spacer() + Button { + state.amount = state.insulinCalculated + } label: { + HStack { + Text( + formatter + .string(from: Double(state.insulinCalculated) as NSNumber) ?? "" + ) + + Text( + String( + localized: + " U", + comment: "Unit in number of units delivered (keep the space character!)" + ) + ).foregroundColor(.secondary) + } + } + .disabled(state.insulinCalculated == 0 || state.amount == state.insulinCalculated) + .buttonStyle(.bordered).padding(.trailing, -10) + } - HStack { - Text("Bolus") - Spacer() - TextFieldWithToolBar( - text: $state.amount, - placeholder: "0", - textColor: colorScheme == .dark ? .white : .blue, - maxLength: 5, - numberFormatter: formatter, - showArrows: true, - previousTextField: { focusedField = previousField(from: .bolus) }, - nextTextField: { focusedField = nextField(from: .bolus) }, - unitsText: String(localized: "U", comment: "Units for bolus amount") - ).focused($focusedField, equals: .bolus) - .onChange(of: state.amount) { - Task { - await state.updateForecasts() - } - } - } + Divider() + Spacer() - HStack { - Text("External Insulin") - Spacer() - Toggle("", isOn: $state.externalInsulin).toggleStyle(CheckboxToggleStyle()) + HStack { + Text("Bolus") + Spacer() + TextFieldWithToolBar( + text: $state.amount, + placeholder: "0", + textColor: colorScheme == .dark ? .white : .blue, + maxLength: 5, + numberFormatter: formatter, + showArrows: true, + previousTextField: { focusedField = previousField(from: .bolus) }, + nextTextField: { focusedField = nextField(from: .bolus) }, + unitsText: String(localized: "U", comment: "Units for bolus amount") + ).focused($focusedField, equals: .bolus) + .onChange(of: state.amount) { + Task { + await state.updateForecasts() } - }.listRowBackground(Color.chart) + } + } + + Divider() + Spacer() - treatmentButton + HStack { + Text("External Insulin") + Spacer() + Toggle("", isOn: $state.externalInsulin).toggleStyle(CheckboxToggleStyle()) + } + + Spacer() + } + } + + func treatmentButtonCompact() -> some View { + var treatmentButtonBackground = Color(.systemBlue) + if limitExceeded { + treatmentButtonBackground = Color(.systemRed) + } else if disableTaskButton { + treatmentButtonBackground = Color(.systemGray) + } + + return Button { + if bolusWarning.shouldConfirm { + showConfirmDialogForBolusing = true + } else { + state.invokeTreatmentsTask() + } + } label: { + HStack { + if state.isBolusInProgress && state.amount > 0 && + !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0) + { + ProgressView() } - .listSectionSpacing(sectionSpacing) + taskButtonLabel + } + .font(.headline) + .foregroundStyle(Color.white) + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 50) + .background(treatmentButtonBackground) + .clipShape(RoundedRectangle(cornerRadius: 15)) + } + .disabled(disableTaskButton) + .shadow(radius: 3) + .padding(.horizontal) + .confirmationDialog( + bolusWarning.warningMessage + " Bolus \(state.amount.description) U?", + isPresented: $showConfirmDialogForBolusing, + titleVisibility: .visible + ) { + Button("Cancel", role: .cancel) {} + Button( + bolusWarning.warningMessage.isEmpty ? "Enact Bolus" : "Ignore Warning and Enact Bolus", + role: bolusWarning.warningMessage.isEmpty ? nil : .destructive + ) { + state.invokeTreatmentsTask() } - .blur(radius: state.isAwaitingDeterminationResult ? 5 : 0) + } + } + + @ViewBuilder func listView() -> some View { + List { + Section { + foodSearch + }.listRowBackground(Color.chart) + + Section { + Group { + ForecastChart(state: state) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + .listRowInsets(EdgeInsets()) + }.listRowBackground(Color.chart) + + Section { + inputsView + .padding(.vertical, 2) + }.listRowBackground(Color.chart) + + Section { + optionsView + .padding(.vertical, 2) + }.listRowBackground(Color.chart) + + treatmentButton + } + .listStyle(.insetGrouped) + .listSectionSpacing(sectionSpacing) + .contentMargins(.top, 0, for: .scrollContent) + } + + var body: some View { + ZStack(alignment: .center) { + listView() + .blur(radius: state.showInfo || state.isAwaitingDeterminationResult ? 3 : 0) if state.isAwaitingDeterminationResult { CustomProgressView(text: progressText.displayName) } } - .padding(.top) - .ignoresSafeArea(edges: .top) - .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme)) - .blur(radius: state.showInfo ? 3 : 0) + .background(appState.trioBackgroundColor(for: colorScheme).ignoresSafeArea()) + .scrollContentBackground(.hidden) .navigationTitle("Treatments") .navigationBarTitleDisplayMode(.inline) .toolbar(content: { ToolbarItem(placement: .topBarLeading) { Button { + UIApplication.shared.endEditing() state.hideModal() } label: { Text("Close") @@ -392,14 +606,17 @@ extension Treatments { } if state.displayPresets { ToolbarItem(placement: .topBarTrailing) { - Button(action: { - showPresetSheet = true - }, label: { - HStack { - Text("Presets") - Image(systemName: "plus") + Button( + action: { + showPresetSheet = true + }, + label: { + HStack { + Text("Presets") + Image(systemName: "plus") + } } - }) + ) } } }) @@ -409,9 +626,9 @@ extension Treatments { Task { @MainActor in state.insulinCalculated = await state.calculateInsulin() } - - if PropertyPersistentFlags.shared.hasSeenFatProteinOrderChange != true { - showFatProteinOrderBanner = true + // Auto-open scanner if requested + if openWithScanner { + configureAndShowScanner(showList: false) } } } @@ -425,18 +642,108 @@ extension Treatments { .sheet(isPresented: $state.showInfo) { PopupView(state: state) } - .sheet(isPresented: $showPresetSheet, onDismiss: { - showPresetSheet = false - }) { + .sheet( + isPresented: $showPresetSheet, + onDismiss: { + showPresetSheet = false + } + ) { MealPresetView(state: state) } - .alert("Error while processing Treatment", isPresented: $state.showDeterminationFailureAlert) { + .alert("Error while processing Treatment", isPresented: $state.showDeterminationFailureAlert) + { Button("OK", role: .cancel) { state.hideModal() } } message: { Text("\(state.determinationFailureMessage)") } + .sheet(isPresented: $showBarcodeScanner) { + NavigationStack { + BarcodeScanner.RootView( + resolver: resolver, + state: scannerState, + showListInitially: initialShowList, + onAddTreatments: { carbs, fat, protein, note in + // Directly merge scanned amounts into Treatments state + Task { @MainActor in + state.addScannedAmounts(carbs: carbs, fat: fat, protein: protein, note: note) + // Force forecasts update and recalc insulin + await state.updateForecasts(force: true) + state.insulinCalculated = await state.calculateInsulin() + } + }, + onDismiss: { showBarcodeScanner = false } + ) + .environment(appState) + } + .onChange(of: scannerState.scannedProducts) { + syncScannedAmounts() + } + } + } + + @StateObject private var scannerState = BarcodeScanner.StateModel() + @State private var showBarcodeScanner = false + @State private var initialShowList = false + + func configureAndShowScanner(showList: Bool) { + scannerState.showListView = showList + showBarcodeScanner = true + initialShowList = showList + } + + /// Adds a search result to the scanned products and updates calculations + private func addSearchResultToMeal(_ item: BarcodeScanner.FoodItem) { + // Add to scanner state's scanned products with default amount + var mutableItem = item + mutableItem.amount = item.servingQuantity ?? 100 // Default to serving or 100g + scannerState.scannedProducts.append(mutableItem) + + // Clear search + scannerState.searchQuery = "" + scannerState.searchResults = [] + + // Sync amounts and recalculate + syncScannedAmounts() + isSearchFocused = false + } + + private func syncScannedAmounts() { + let totalCarbs = scannerState.scannedProducts.reduce(into: 0.0) { result, item in + let carbsPer100 = item.nutriments.carbohydratesPer100g ?? 0 + let amount = item.amount.isFinite ? item.amount : 0 + result += (carbsPer100 * amount) / 100.0 + } + let totalProtein = scannerState.scannedProducts.reduce(into: 0.0) { result, item in + let protPer100 = item.nutriments.proteinPer100g ?? 0 + let amount = item.amount.isFinite ? item.amount : 0 + result += (protPer100 * amount) / 100.0 + } + let totalFat = scannerState.scannedProducts.reduce(into: 0.0) { result, item in + let fatPer100 = item.nutriments.fatPer100g ?? 0 + let amount = item.amount.isFinite ? item.amount : 0 + result += (fatPer100 * amount) / 100.0 + } + + state.scannedCarbs = Decimal(totalCarbs) + state.scannedProtein = Decimal(totalProtein) + state.scannedFat = Decimal(totalFat) + + // Trigger a recalculation immediately (sheet may make view inactive, so do it directly) + Task { @MainActor in + // Update forecasts and insulin immediately (force update even if view not active) + debug( + .bolusState, + "syncScannedAmounts: carbs=\(state.carbs) scannedCarbs=\(state.scannedCarbs) totalCarbs=\(state.carbs + state.scannedCarbs)" + ) + await state.updateForecasts(force: true) + state.insulinCalculated = await state.calculateInsulin() + debug(.bolusState, "syncScannedAmounts: insulinCalculated=\(state.insulinCalculated)") + } + + // Also keep the debounced update for smoother UI updates + handleDebouncedInput() } var progressText: ProgressText { @@ -463,11 +770,13 @@ extension Treatments { return (false, "", .primary) } - let warningMessage = isGlucoseVeryLow ? String(localized: "Glucose is very low.") : - isForecastVeryLow ? String(localized: "Glucose forecast is very low.") : - "" + let warningMessage = + isGlucoseVeryLow + ? String(localized: "Glucose is very low.") + : isForecastVeryLow ? String(localized: "Glucose forecast is very low.") : "" - let warningColor: Color = isGlucoseVeryLow ? .red : colorScheme == .dark ? .orange : .accentColor + let warningColor: Color = + isGlucoseVeryLow ? .red : colorScheme == .dark ? .orange : .accentColor let shouldConfirm = state.confirmBolus && (isGlucoseVeryLow || isForecastVeryLow) @@ -492,7 +801,12 @@ extension Treatments { } label: { HStack { if state.isBolusInProgress && state.amount > 0 && - !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0) + !state + .externalInsulin && + ( + state.carbs == 0 && state.scannedCarbs == 0 || state.fat == 0 && state.scannedFat == 0 || state + .protein == 0 && state.scannedProtein == 0 + ) { ProgressView() } @@ -501,7 +815,7 @@ extension Treatments { .font(.headline) .foregroundStyle(Color.white) .frame(maxWidth: .infinity, alignment: .center) - .frame(height: 35) + .frame(height: 20) } .disabled(disableTaskButton) .listRowBackground(treatmentButtonBackground) @@ -546,11 +860,13 @@ extension Treatments { } let hasInsulin = state.amount > 0 - let hasCarbs = state.carbs > 0 - let hasFatOrProtein = state.fat > 0 || state.protein > 0 + let hasCarbs = state.carbs > 0 || state.scannedCarbs > 0 + let hasFatOrProtein = state.fat > 0 || state.scannedFat > 0 || state.protein > 0 || state.scannedProtein > 0 let bolusString = state.externalInsulin ? String(localized: "External Insulin") : String(localized: "Enact Bolus") - if state.isBolusInProgress && hasInsulin && !state.externalInsulin && (!hasCarbs || !hasFatOrProtein) { + if state.isBolusInProgress && hasInsulin && !state.externalInsulin + && (!hasCarbs || !hasFatOrProtein) + { return Text("Bolus In Progress...") } @@ -583,25 +899,31 @@ extension Treatments { } private var carbLimitExceeded: Bool { - state.carbs > state.maxCarbs + (state.carbs + state.scannedCarbs) > state.maxCarbs } private var fatLimitExceeded: Bool { - state.fat > state.maxFat + (state.fat + state.scannedFat) > state.maxFat } private var proteinLimitExceeded: Bool { - state.protein > state.maxProtein + (state.protein + state.scannedProtein) > state.maxProtein } private var limitExceeded: Bool { - pumpBolusLimitExceeded || externalBolusLimitExceeded || carbLimitExceeded || fatLimitExceeded || proteinLimitExceeded + pumpBolusLimitExceeded || externalBolusLimitExceeded || carbLimitExceeded || fatLimitExceeded + || proteinLimitExceeded } private var disableTaskButton: Bool { ( state.isBolusInProgress && state - .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0) + .amount > 0 && !state + .externalInsulin && + ( + state.carbs == 0 && state.scannedCarbs == 0 || state.fat == 0 && state.scannedFat == 0 || state + .protein == 0 && state.scannedProtein == 0 + ) ) || state .addButtonPressed || limitExceeded } diff --git a/Trio/Sources/Router/Screen.swift b/Trio/Sources/Router/Screen.swift index 1bfc27857f1..56d4dd3166e 100644 --- a/Trio/Sources/Router/Screen.swift +++ b/Trio/Sources/Router/Screen.swift @@ -15,7 +15,9 @@ enum Screen: Identifiable, Hashable { case isfEditor case crEditor case targetsEditor + case barcodeScanner case treatmentView + case treatmentWithScanner case manualTempBasal case history case cgm @@ -91,8 +93,15 @@ extension Screen { CarbRatioEditor.RootView(resolver: resolver) case .targetsEditor: TargetsEditor.RootView(resolver: resolver) + case .barcodeScanner: + BarcodeScanner.RootView(resolver: resolver, state: BarcodeScanner.StateModel()) case .treatmentView: Treatments.RootView(resolver: resolver) + case .treatmentWithScanner: + Treatments.RootView( + resolver: resolver, + openWithScanner: true + ) case .manualTempBasal: ManualTempBasal.RootView(resolver: resolver) case .history: diff --git a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift index f0f70935be2..be736a1825d 100644 --- a/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift +++ b/Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift @@ -11,6 +11,7 @@ import UserNotifications protocol UserNotificationsManager { func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void) func requestNotificationPermissions(completion: @escaping (Bool) -> Void) + @MainActor func applySnooze(for duration: TimeInterval) async } enum GlucoseSourceKey: String { @@ -40,6 +41,12 @@ protocol pumpNotificationObserver { func pumpRemoveNotification() } +// MARK: - SnoozeObserver Protocol + +protocol SnoozeObserver { + @MainActor func snoozeDidChange(_ untilDate: Date) +} + final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable { enum Identifier: String { case glucoseNotification = "Trio.glucoseNotification" @@ -61,6 +68,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider! @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast + // The glucose notification observers below (Core Data saves and the storage publisher) can fire for the same + // reading, so we persist the last alert token to avoid enqueueing identical high/low notifications multiple times. + @Persisted(key: "UserNotificationsManager.lastGlucoseAlertToken") private var lastGlucoseAlertToken: String = "" private let notificationCenter = UNUserNotificationCenter.current() private var lifetime = Lifetime() @@ -95,11 +105,28 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In Task { await sendGlucoseNotification() } + configureNotificationCategories() registerHandlers() registerSubscribers() subscribeOnLoop() } + private func configureNotificationCategories() { + notificationCenter.getNotificationCategories { [weak self] existingCategories in + guard let self else { return } + + let glucoseCategory = NotificationCategoryFactory.createGlucoseCategory() + + var categories = existingCategories + categories.update(with: glucoseCategory) + // UNUserNotificationCenter methods should be called on main thread + Task { @MainActor [weak self] in + guard let self else { return } + self.notificationCenter.setNotificationCategories(categories) + } + } + } + private func subscribeOnLoop() { apsManager.lastLoopDateSubject .sink { [weak self] date in @@ -271,6 +298,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In try viewContext.existingObject(with: id) as? GlucoseStored } + if glucoseStorage.alarm == .none { + lastGlucoseAlertToken = "" + } + guard let lastReading = glucoseObjects.first?.glucose, let secondLastReading = glucoseObjects.dropFirst().first?.glucose, let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return } @@ -305,6 +336,15 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In titles.append(String(localized: "(Snoozed)", comment: "(Snoozed)")) notificationAlarm = false } else { + let token = alertToken(from: glucoseObjects.first) + + if token == "unknown" { + warning(.service, "Missing glucose token fields; skipping notification to avoid re-alerting") + return + } + if notificationAlarm, token == lastGlucoseAlertToken { + return + } titles.append(body) let content = UNMutableNotificationContent() content.title = titles.joined(separator: " ") @@ -313,6 +353,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In if notificationAlarm { content.sound = .default content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue + content.categoryIdentifier = NotificationCategoryIdentifier.trioAlert.rawValue } addRequest( @@ -323,6 +364,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In messageSubtype: .glucose, action: NotificationAction.snooze ) + if notificationAlarm { + lastGlucoseAlertToken = token + } } } catch { debugPrint( @@ -331,6 +375,23 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In } } + private func alertToken(from glucose: GlucoseStored?) -> String { + if let id = glucose?.id?.uuidString { return id } + + if let date = glucose?.date { + let roundedMinute = Int((date.timeIntervalSince1970 / 60).rounded()) + return "date-\(roundedMinute)" + } + + // Stable fallback for Core Data objects: + if let glucose, !glucose.objectID.isTemporaryID { + return "objectID-\(glucose.objectID.uriRepresentation().absoluteString)" + } + + // Stable “unknown” fallback: prevents repeated alarms when identifiers are missing + return "unknown" + } + private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String { let units = settingsManager.settings.units let glucoseText = glucoseFormatter @@ -409,6 +470,19 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In } } + @MainActor func applySnooze(for duration: TimeInterval) async { + let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast + snoozeUntilDate = untilDate + lastGlucoseAlertToken = "" + // removeGlucoseNotifications() is safe to call here since we're @MainActor + removeGlucoseNotifications() + + // Notify observers that snooze was applied + broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in + observer.snoozeDidChange(untilDate) + } + } + private func addRequest( identifier: Identifier, content: UNMutableNotificationContent, @@ -571,6 +645,14 @@ extension BaseUserNotificationsManager: pumpNotificationObserver { self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue]) } } + + /// Removes all glucose notifications (delivered and pending). + /// Must be called from the main thread. Safe to call from @MainActor contexts. + @MainActor private func removeGlucoseNotifications() { + let identifier = Identifier.glucoseNotification.rawValue + notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier]) + notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier]) + } } extension BaseUserNotificationsManager: DeterminationObserver { @@ -595,29 +677,46 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { completionHandler([.banner, .badge, .sound, .list]) } + /// UNUserNotificationCenterDelegate method called when user interacts with a notification. + /// This can be called off the main thread, so we ensure all work happens on @MainActor. func userNotificationCenter( _: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { defer { completionHandler() } + + // Handle quick snooze actions (from notification action buttons) + if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) { + Task { @MainActor [weak self] in + guard let self else { return } + await self.applySnooze(for: quickAction.duration) + } + return + } + + // Handle other notification actions (e.g., tapping notification body) guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String, let action = NotificationAction(rawValue: actionRaw) else { return } - switch action { - case .snooze: - router.mainModalScreen.send(.snooze) - case .pumpConfig: - let messageCont = MessageContent( - content: response.notification.request.content.body, - type: MessageType.other, - subtype: .pump, - useAPN: false, - action: .pumpConfig - ) - router.alertMessage.send(messageCont) - default: break + // Ensure UI operations happen on main thread using Task for consistency + Task { @MainActor [weak self] in + guard let self = self else { return } + switch action { + case .snooze: + self.router.mainModalScreen.send(.snooze) + case .pumpConfig: + let messageCont = MessageContent( + content: response.notification.request.content.body, + type: MessageType.other, + subtype: .pump, + useAPN: false, + action: .pumpConfig + ) + self.router.alertMessage.send(messageCont) + default: break + } } } } diff --git a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift index d93bf99682e..70fb43c880f 100644 --- a/Trio/Sources/Services/WatchManager/AppleWatchManager.swift +++ b/Trio/Sources/Services/WatchManager/AppleWatchManager.swift @@ -25,6 +25,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana @Injected() private var tempTargetStorage: TempTargetsStorage! @Injected() private var bolusCalculationManager: BolusCalculationManager! @Injected() private var iobService: IOBService! + @Injected() private var notificationsManager: UserNotificationsManager! private var units: GlucoseUnits = .mgdL private var glucoseColorScheme: GlucoseColorScheme = .staticColor @@ -553,16 +554,18 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana } func session(_: WCSession, didReceiveMessage message: [String: Any]) { - DispatchQueue.main.async { [weak self] in - if let logs = message["watchLogs"] as? String { - SimpleLogReporter.appendToWatchLog(logs) - } + // Handle logs first - doesn't need self, so it can run even during teardown + if let logs = message["watchLogs"] as? String { + SimpleLogReporter.appendToWatchLog(logs) + } + + Task { @MainActor [weak self] in + guard let self else { return } if let requestWatchUpdate = message[WatchMessageKeys.requestWatchUpdate] as? String, requestWatchUpdate == WatchMessageKeys.watchState { debug(.watchManager, "📱 Watch requested watch state data update.") - guard let self = self else { return } // Skip if no watch is paired or app not installed guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return } @@ -573,19 +576,23 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana return } - if let bolusAmount = message[WatchMessageKeys.bolus] as? Double, - message[WatchMessageKeys.carbs] == nil, - message[WatchMessageKeys.date] == nil + if let snoozeMinutes = message[WatchMessageKeys.snoozeDuration] as? Int { + debug(.watchManager, "📱 Received snooze request from watch: \(snoozeMinutes) minutes") + await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60)) + return + } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double, + message[WatchMessageKeys.carbs] == nil, + message[WatchMessageKeys.date] == nil { debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U") - self?.handleBolusRequest(Decimal(bolusAmount)) + self.handleBolusRequest(Decimal(bolusAmount)) } else if let carbsAmount = message[WatchMessageKeys.carbs] as? Int, let timestamp = message[WatchMessageKeys.date] as? TimeInterval, message[WatchMessageKeys.bolus] == nil { let date = Date(timeIntervalSince1970: timestamp) debug(.watchManager, "📱 Received carbs request from watch: \(carbsAmount)g at \(date)") - self?.handleCarbsRequest(carbsAmount, date) + self.handleCarbsRequest(carbsAmount, date) } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double, let carbsAmount = message[WatchMessageKeys.carbs] as? Int, let timestamp = message[WatchMessageKeys.date] as? TimeInterval @@ -595,11 +602,11 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana .watchManager, "📱 Received meal bolus combo request from watch: \(bolusAmount)U, \(carbsAmount)g at \(date)" ) - self?.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date) + self.handleCombinedRequest(bolusAmount: Decimal(bolusAmount), carbsAmount: Decimal(carbsAmount), date: date) } else { debug(.watchManager, "📱 Invalid or incomplete data received from watch. Received: \(message)") // Acknowledge failure - self?.sendAcknowledgment( + self.sendAcknowledgment( toWatch: false, message: "Error! Invalid or incomplete data received from watch.", ackCode: .genericFailure @@ -608,22 +615,22 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana if message[WatchMessageKeys.cancelOverride] as? Bool == true { debug(.watchManager, "📱 Received cancel override request from watch") - self?.handleCancelOverride() + self.handleCancelOverride() } if let presetName = message[WatchMessageKeys.activateOverride] as? String { debug(.watchManager, "📱 Received activate override request from watch for preset: \(presetName)") - self?.handleActivateOverride(presetName) + self.handleActivateOverride(presetName) } if let presetName = message[WatchMessageKeys.activateTempTarget] as? String { debug(.watchManager, "📱 Received activate temp target request from watch for preset: \(presetName)") - self?.handleActivateTempTarget(presetName) + self.handleActivateTempTarget(presetName) } if message[WatchMessageKeys.cancelTempTarget] as? Bool == true { debug(.watchManager, "📱 Received cancel temp target request from watch") - self?.handleCancelTempTarget() + self.handleCancelTempTarget() } if message[WatchMessageKeys.requestBolusRecommendation] as? Bool == true { @@ -684,6 +691,14 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana if let logs = userInfo["watchLogs"] as? String { SimpleLogReporter.appendToWatchLog(logs) } + + if let snoozeMinutes = userInfo[WatchMessageKeys.snoozeDuration] as? Int { + debug(.watchManager, "📱 Received snooze userInfo from watch: \(snoozeMinutes) minutes") + Task { @MainActor [weak self] in + guard let self else { return } + await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60)) + } + } } #if os(iOS) diff --git a/Trio/Sources/Shortcuts/AppShortcuts.swift b/Trio/Sources/Shortcuts/AppShortcuts.swift index d5f68e2217e..b3f08697ae2 100644 --- a/Trio/Sources/Shortcuts/AppShortcuts.swift +++ b/Trio/Sources/Shortcuts/AppShortcuts.swift @@ -75,5 +75,15 @@ struct AppShortcuts: AppShortcutsProvider { shortTitle: "Restart Live Activity", systemImageName: "arrow.clockwise.circle.fill" ) + AppShortcut( + intent: OpenBarcodeScannerIntent(), + phrases: [ + "Open \(.applicationName) Barcode Scanner", + "Scan food with \(.applicationName)", + "\(.applicationName) food scanner" + ], + shortTitle: "Barcode AI Scanner", + systemImageName: "barcode.viewfinder" + ) } } diff --git a/Trio/Sources/Shortcuts/BarcodeScanner/OpenBarcodeScannerIntent.swift b/Trio/Sources/Shortcuts/BarcodeScanner/OpenBarcodeScannerIntent.swift new file mode 100644 index 00000000000..ff25eb0b84d --- /dev/null +++ b/Trio/Sources/Shortcuts/BarcodeScanner/OpenBarcodeScannerIntent.swift @@ -0,0 +1,25 @@ +import AppIntents +import Foundation + +/// App Intent used to open the Barcode AI scanner via Apple Shortcuts. +/// When invoked, this intent opens the app and navigates to the Barcode AI view +/// for scanning food barcodes and analyzing food images. +struct OpenBarcodeScannerIntent: AppIntent { + /// Title of the action in the Shortcuts app. + static var title = LocalizedStringResource("Open Barcode Scanner") + + /// Description of the action in the Shortcuts app. + static var description = IntentDescription(.init("Opens Trio's Barcode scanner for scanning processed food")) + + /// This intent opens the app when run + static var openAppWhenRun: Bool = true + + /// Performs the intent by opening the Barcode view. + /// + /// - Returns: An intent result indicating the action was triggered. + @MainActor func perform() async throws -> some IntentResult { + // Post notification to open BarcodeAI view + Foundation.NotificationCenter.default.post(name: .openBarcode, object: nil) + return .result() + } +} diff --git a/Trio/Sources/Views/NutritionTextField.swift b/Trio/Sources/Views/NutritionTextField.swift new file mode 100644 index 00000000000..2ee57ecda04 --- /dev/null +++ b/Trio/Sources/Views/NutritionTextField.swift @@ -0,0 +1,386 @@ +import SwiftUI +import UIKit + +// MARK: - Nutrition Text Field + +/// A reusable text field for editing nutrition values (carbs, fat, protein, etc.) +/// with built-in keyboard toolbar and decimal separator handling. +/// Uses UIKit internally to ensure toolbar visibility. +public struct NutritionTextField: View { + let label: String + @Binding var value: Double + let unit: String + let field: Field + var focusedField: FocusState.Binding + + let numberFormatter: NumberFormatter + + public init( + label: String, + value: Binding, + unit: String = "g", + field: Field, + focusedField: FocusState.Binding, + numberFormatter: NumberFormatter? = nil + ) { + self.label = label + _value = value + self.unit = unit + self.field = field + self.focusedField = focusedField + + if let formatter = numberFormatter { + self.numberFormatter = formatter + } else { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + formatter.locale = Locale.current + self.numberFormatter = formatter + } + } + + public var body: some View { + let isFocused = focusedField.wrappedValue == field + ZStack { + KeyboardToolbarTextField( + value: $value, + formatter: numberFormatter, + configuration: .init( + keyboardType: .decimalPad, + textAlignment: .right, + placeholder: "0" + ), + onFocusContext: { isFocused in + if isFocused { + focusedField.wrappedValue = field + } else if focusedField.wrappedValue == field { + focusedField.wrappedValue = nil + } + }, + externalFocus: isFocused + ) + .padding(.horizontal) + .padding(.trailing, 25) // Extra padding to avoid overlapping with unit + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + + // Overlays: label (left) and unit (right) — don't intercept taps + HStack { + Text(label) + .foregroundStyle(isFocused ? .primary : .secondary) + .font(.subheadline.weight(isFocused ? .semibold : .regular)) + .allowsHitTesting(false) + Spacer() + Text(unit) + .foregroundStyle(.secondary) + .frame(width: 20, alignment: .trailing) + .allowsHitTesting(false) + } + .padding(.horizontal) + .padding(.vertical, 10) + } + .background(isFocused ? Color.accentColor.opacity(0.15) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + focusedField.wrappedValue = field + } + .animation(.easeInOut(duration: 0.15), value: isFocused) + } +} + +// MARK: - Amount Text Field + +/// A reusable text field for entering amounts (weight/volume) with unit toggle. +public struct AmountTextField: View { + @Binding var amount: Double + @Binding var isMl: Bool + let field: Field + var focusedField: FocusState.Binding + + let numberFormatter: NumberFormatter + + public init( + amount: Binding, + isMl: Binding, + field: Field, + focusedField: FocusState.Binding, + numberFormatter: NumberFormatter? = nil + ) { + _amount = amount + _isMl = isMl + self.field = field + self.focusedField = focusedField + + if let formatter = numberFormatter { + self.numberFormatter = formatter + } else { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + formatter.locale = Locale.current + self.numberFormatter = formatter + } + } + + public var body: some View { + let isFocused = focusedField.wrappedValue == field + + HStack(spacing: 12) { + HStack(spacing: 4) { + KeyboardToolbarTextField( + value: $amount, + formatter: numberFormatter, + configuration: .init( + keyboardType: .decimalPad, + textAlignment: .right, + placeholder: "0", + font: .systemFont(ofSize: 22, weight: .semibold) + ), + onFocusContext: { isFocused in + if isFocused { + focusedField.wrappedValue = field + } else if focusedField.wrappedValue == field { + focusedField.wrappedValue = nil + } + }, + externalFocus: isFocused + ) + .frame(minWidth: 110, maxWidth: 140) + + Text(isMl ? "ml" : "g") + .font(.title3) + .foregroundStyle(.secondary) + .padding(.leading, 6) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.secondary.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + // Unit toggle + Picker("Unit", selection: $isMl) { + Text("g").tag(false) + Text("ml").tag(true) + } + .pickerStyle(.segmented) + .frame(width: 100) + } + } +} + +// MARK: - Internal UIKit Implementation + +public struct KeyboardToolbarTextField: UIViewRepresentable { + @Binding var value: Double + public let formatter: NumberFormatter + public let configuration: Configuration + public let onFocusContext: (Bool) -> Void + public let externalFocus: Bool + + public struct Configuration { + public var keyboardType: UIKeyboardType = .decimalPad + public var textAlignment: NSTextAlignment = .right + public var placeholder: String = "" + public var font: UIFont? + public var textColor: UIColor = .label + + public init( + keyboardType: UIKeyboardType = .decimalPad, + textAlignment: NSTextAlignment = .right, + placeholder: String = "", + font: UIFont? = nil, + textColor: UIColor = .label + ) { + self.keyboardType = keyboardType + self.textAlignment = textAlignment + self.placeholder = placeholder + self.font = font + self.textColor = textColor + } + } + + public init( + value: Binding, + formatter: NumberFormatter, + configuration: Configuration = .init(), + onFocusContext: @escaping (Bool) -> Void = { _ in }, + externalFocus: Bool = false + ) { + _value = value + self.formatter = formatter + self.configuration = configuration + self.onFocusContext = onFocusContext + self.externalFocus = externalFocus + } + + public func makeUIView(context: Context) -> UITextField { + let textField = UITextField() + textField.delegate = context.coordinator + textField.keyboardType = configuration.keyboardType + textField.textAlignment = configuration.textAlignment + textField.placeholder = configuration.placeholder + if let font = configuration.font { + textField.font = font + } + textField.textColor = configuration.textColor + + // Setup Toolbar + let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50)) + toolbar.items = [ + UIBarButtonItem( + image: UIImage(systemName: "trash"), + style: .plain, + target: context.coordinator, + action: #selector(Coordinator.clearText) + ), + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + UIBarButtonItem( + image: UIImage(systemName: "keyboard.chevron.compact.down"), + style: .done, + target: textField, + action: #selector(UITextField.resignFirstResponder) + ) + ] + toolbar.sizeToFit() + textField.inputAccessoryView = toolbar + context.coordinator.textField = textField + + return textField + } + + public func updateUIView(_ uiView: UITextField, context: Context) { + context.coordinator.parent = self + // Clear entering focus flag if we have achieved consistency + if externalFocus { + context.coordinator.isEnteringFocus = false + } + + // Sync value to text only if not currently editing (to avoid cursor jumping/formatting issues while typing) + // OR if the value changed externally significantly + if !uiView.isEditing { + updateTextField(uiView) + } + + // External focus handling + if externalFocus && !uiView.isFirstResponder { + DispatchQueue.main.async { + uiView.becomeFirstResponder() + } + } else if !externalFocus, uiView.isFirstResponder { + // Only force resign if we are NOT in the middle of entering focus + // This prevents race conditions where parent view updates (e.g. keyboard safe area) + // trigger updateUIView before the binding has propagated the focus state. + if !context.coordinator.isEnteringFocus { + DispatchQueue.main.async { + uiView.resignFirstResponder() + } + } + } + } + + private func updateTextField(_ textField: UITextField) { + if value == 0 { + textField.text = "" + } else { + textField.text = formatter.string(from: NSNumber(value: value)) + } + } + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public class Coordinator: NSObject, UITextFieldDelegate { + var parent: KeyboardToolbarTextField + weak var textField: UITextField? + var isEnteringFocus = false + + init(_ parent: KeyboardToolbarTextField) { + self.parent = parent + } + + @objc func clearText() { + parent.value = 0 + textField?.text = "" + } + + public func textFieldShouldBeginEditing(_: UITextField) -> Bool { + isEnteringFocus = true + return true + } + + public func textFieldDidBeginEditing(_ textField: UITextField) { + parent.onFocusContext(true) + // Move cursor to end of document + DispatchQueue.main.async { + let newPosition = textField.endOfDocument + textField.selectedTextRange = textField.textRange(from: newPosition, to: newPosition) + } + } + + public func textFieldDidEndEditing(_ textField: UITextField) { + isEnteringFocus = false + parent.onFocusContext(false) + // Final format on end editing + parent.updateTextField(textField) + } + + public func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + guard let currentText = textField.text as NSString? else { return true } + let newText = currentText.replacingCharacters(in: range, with: string) + + // Allow empty (clearing) + if newText.isEmpty { + parent.value = 0 + return true + } + + // Normalize decimal separator + let localeSeparator = parent.formatter.decimalSeparator ?? "." + var paramText = newText + + // Replace common separators with locale specific one + if localeSeparator == "." { + paramText = paramText.replacingOccurrences(of: ",", with: ".") + } else if localeSeparator == "," { + paramText = paramText.replacingOccurrences(of: ".", with: ",") + } + + // Check if it's a valid number format (allow partial inputs like "0.") + // Direct Double conversion might fail for "0." so we handle it manually + + // Special case: just decimal separator -> "0." + if paramText == localeSeparator { + // We don't update binding yet, or update to 0, but allow text change + return true + } + + // Validate if valid decimal number + // Using NumberFormatter to parse is safer for locale support + if parent.formatter.number(from: paramText) != nil { + // Update binding + // We need to convert back to Double. + // NOTE: NSNumber(value: double) -> string uses formatter + // Here we want string -> double + if let num = parent.formatter.number(from: paramText) { + parent.value = num.doubleValue + } + return true + } else if paramText.hasSuffix(localeSeparator) { + // Allow typing the separator even if number(from:) returns nil (sometimes) + // But check if prefix is number + let prefix = String(paramText.dropLast()) + if parent.formatter.number(from: prefix) != nil || prefix.isEmpty { + return true + } + } + + return false + } + } +} diff --git a/Trio/Sources/Views/ViewModifiers.swift b/Trio/Sources/Views/ViewModifiers.swift index 593216112ac..cf88fec7eb6 100644 --- a/Trio/Sources/Views/ViewModifiers.swift +++ b/Trio/Sources/Views/ViewModifiers.swift @@ -81,7 +81,19 @@ struct ScreenNavigation: ViewModifier where T: View { func body(content: Content) -> some View { content.navigationDestination( for: Screen.self, - destination: { screen in NavigationLazyView(destination(screen).asAny(), screen: screen) } + destination: { screen in + let view = NavigationLazyView(destination(screen).asAny(), screen: screen) + #if canImport(UIKit) + view + .safeAreaPadding(.bottom, UIDevice.adjustPadding(min: 90, max: 100) ?? 90) + .ignoresSafeArea(.container, edges: .bottom) + .ignoresSafeArea(.keyboard, edges: .bottom) + #else + view + .safeAreaPadding(.bottom, 90) + .ignoresSafeArea(.container, edges: .bottom) + #endif + } ) } } diff --git a/TrioTests/GlucoseSmoothingTests.swift b/TrioTests/GlucoseSmoothingTests.swift index 4bfb03595b5..a39af08ce33 100644 --- a/TrioTests/GlucoseSmoothingTests.swift +++ b/TrioTests/GlucoseSmoothingTests.swift @@ -120,46 +120,64 @@ import Testing } @Test( - "Exponential smoothing stops window at gaps >= 12 minutes; fallback fills smoothed glucose" + "Exponential smoothing stops at gaps >= 12 minutes and only updates the most recent window" ) func testExponentialSmoothingGapStopsWindow() async throws { - // GIVEN: let now = Date() - let dates: [Date] = [ - now.addingTimeInterval(0), // oldest - now.addingTimeInterval(5 * 60), - now.addingTimeInterval(10 * 60), - now.addingTimeInterval(25 * 60), // gap of 15 minutes - now.addingTimeInterval(30 * 60), - now.addingTimeInterval(35 * 60) // newest - ] - let values: [Int16] = [100, 105, 110, 115, 120, 125] + + var dates: [Date] = [] + var values: [Int16] = [] + + // Older contiguous block (should remain untouched) + for i in 0 ..< 10 { + dates.append(now.addingTimeInterval(Double(i) * 5 * 60)) + values.append(Int16(100 + i * 5)) + } + + // GAP (15 minutes) + let gapStart = now.addingTimeInterval(Double(10) * 5 * 60 + 15 * 60) + + // Recent block (too small -> fallback applies only here) + for i in 0 ..< 3 { + dates.append(gapStart.addingTimeInterval(Double(i) * 5 * 60)) + values.append(Int16(200 + i * 5)) + } + await createGlucoseSequence(values: values, dates: dates, isManual: false) - // WHEN await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext) - // THEN let ascending = try await fetchAndSortGlucose() - #expect(ascending.count == 6) + #expect(ascending.count == values.count) - let smoothedValues = ascending - .filter { !$0.isManual } - .compactMap { $0.smoothedGlucose?.decimalValue } - .filter { $0 > 0 } + // Split into: + // - older block (before gap) + // - recent block (after gap) + let olderBlock = ascending.prefix(10) + let recentBlock = ascending.suffix(3) - #expect( - smoothedValues.count == 6, - "Fallback path should fill smoothedGlucose for all CGM entries when the gap reduces the window below minimum size." - ) + // --- ASSERT 1: Older values should NOT be overwritten --- + for (index, obj) in olderBlock.enumerated() { + #expect( + obj.smoothedGlucose == nil, + "Older value at index \(index) should remain untouched (no fallback overwrite)." + ) + } + + // --- ASSERT 2: Recent values should be filled by fallback --- + for (index, obj) in recentBlock.enumerated() { + guard let smoothed = obj.smoothedGlucose?.decimalValue else { + #expect(false, "Recent value at index \(index) should have smoothedGlucose set.") + continue + } - for (index, smoothed) in smoothedValues.enumerated() { #expect( smoothed >= 39, - "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)." + "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed)." ) + #expect( smoothed == smoothed.rounded(toPlaces: 0), - "Fallback smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)." + "Fallback smoothed glucose must be rounded to integer, got \(smoothed)." ) } } diff --git a/scripts/swiftformat.sh b/scripts/swiftformat.sh index 113d7ba06cb..3dafbc8f6fa 100755 --- a/scripts/swiftformat.sh +++ b/scripts/swiftformat.sh @@ -111,4 +111,5 @@ trailingClosures \ OmniBLE, \ MinimedKit, \ TidepoolService, \ - DanaKit + DanaKit, \ + MedtrumKit